From 6fe1f34df22310665384e5a208af5ba74701366c Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:50:44 +0200 Subject: [PATCH] feat: add transcript history view with corrections, search and copy Co-Authored-By: Claude Sonnet 5.5 --- Sources/Sst/HistoryView.swift | 121 +++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/Sources/Sst/HistoryView.swift b/Sources/Sst/HistoryView.swift index b5132eb..3d7b331 100644 --- a/Sources/Sst/HistoryView.swift +++ b/Sources/Sst/HistoryView.swift @@ -1,9 +1,126 @@ +import AppKit +import SstCore import SstData import SstUI import SwiftUI -// Temporary: replaced in Task 4. +private let dutch = Locale(identifier: "nl_NL") + struct HistoryView: View { + @EnvironmentObject private var model: AppModel @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) + } }