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 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user