feat: add transcript history view with corrections, search and copy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,126 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstCore
|
||||||
import SstData
|
import SstData
|
||||||
import SstUI
|
import SstUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// Temporary: replaced in Task 4.
|
private let dutch = Locale(identifier: "nl_NL")
|
||||||
|
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
@ObservedObject var list: HistoryViewModel
|
@ObservedObject var list: HistoryViewModel
|
||||||
var body: some View { PaperBackground() }
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
SearchField(text: $list.query, focused: $searchFocused)
|
||||||
|
ZStack {
|
||||||
|
PaperBackground()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if list.days.isEmpty {
|
||||||
|
Text(emptyMessage)
|
||||||
|
.textStyle(Typography.body)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
} else {
|
||||||
|
ScrollList {
|
||||||
|
ForEach(list.days) { day in
|
||||||
|
Section {
|
||||||
|
ForEach(day.entries) { entry in
|
||||||
|
HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch)))
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Palette.surfacePaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
.onDeleteCommand {
|
||||||
|
if let id = list.selection { list.delete(id: id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyMessage: String {
|
||||||
|
if list.totalCount > 0 { return Strings.noResults }
|
||||||
|
return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HistoryRow: View {
|
||||||
|
let entry: HistoryEntry
|
||||||
|
let isSelected: Bool
|
||||||
|
@ObservedObject var list: HistoryViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
|
||||||
|
Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch)))
|
||||||
|
.textStyle(Typography.counterSmall)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
VStack(alignment: .leading, spacing: Space.s2) {
|
||||||
|
Text(markedText)
|
||||||
|
.textStyle(Typography.reading)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
if !entry.corrections.isEmpty {
|
||||||
|
SilkLabel(correctionSummary, style: Typography.labelSmall)
|
||||||
|
}
|
||||||
|
if isSelected && entry.rawText != entry.finalText {
|
||||||
|
Text("\(Strings.original): \(entry.rawText)")
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: Space.s4)
|
||||||
|
Button(Strings.copy) { copy(entry.finalText) }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.frame(minHeight: Size.rowHeight)
|
||||||
|
.background(isSelected ? Palette.surfaceSelection : Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { list.selection = entry.id }
|
||||||
|
.contextMenu {
|
||||||
|
Button(Strings.copy) { copy(entry.finalText) }
|
||||||
|
Button(Strings.copyOriginal) { copy(entry.rawText) }
|
||||||
|
Divider()
|
||||||
|
Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final text with every dictionary replacement highlighted.
|
||||||
|
private var markedText: AttributedString {
|
||||||
|
var result = AttributedString()
|
||||||
|
for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) {
|
||||||
|
var piece = AttributedString(segment.text)
|
||||||
|
if segment.correction != nil {
|
||||||
|
piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark)
|
||||||
|
}
|
||||||
|
result += piece
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var correctionSummary: String {
|
||||||
|
let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ")
|
||||||
|
return "\(Strings.corrections(entry.corrections.count)) · \(changes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy(_ text: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user