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:
2026-10-01 15:50:44 +02:00
co-authored by Claude Sonnet 5.5
parent f002dbe249
commit 6fe1f34df2
+119 -2
View File
@@ -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)
}
}