Redesign transcription history with three-pane layout and compact audio player

- Implement three-pane Xcode-style layout with toggleable sidebars
- Refactor into modular components for better maintainability
- Replace tabbed interface with iMessage-style message bubbles
- Redesign audio player for 70% height reduction with horizontal layout
- Update to native macOS colors and remove custom backgrounds
- Increase minimum window size to 1000x700 for better usability
This commit is contained in:
Beingpax 2025-12-29 11:44:50 +05:45
parent ebad2c42d0
commit 531da7b172
7 changed files with 843 additions and 502 deletions

View File

@ -30,12 +30,12 @@ class HistoryWindowController: NSObject, NSWindowDelegate {
let historyView = TranscriptionHistoryView()
.modelContainer(modelContainer)
.environmentObject(whisperState)
.frame(minWidth: 800, minHeight: 600)
.frame(minWidth: 1000, minHeight: 700)
let hostingController = NSHostingController(rootView: historyView)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 900, height: 700),
contentRect: NSRect(x: 0, y: 0, width: 1100, height: 750),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
@ -50,7 +50,7 @@ class HistoryWindowController: NSObject, NSWindowDelegate {
window.backgroundColor = NSColor.windowBackgroundColor
window.isReleasedWhenClosed = false
window.collectionBehavior = [.fullScreenPrimary]
window.minSize = NSSize(width: 700, height: 500)
window.minSize = NSSize(width: 1000, height: 700)
window.setFrameAutosaveName(windowAutosaveName)
if !window.setFrameUsingName(windowAutosaveName) {

View File

@ -118,16 +118,16 @@ struct WaveformView: View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
if isLoading {
VStack {
HStack {
ProgressView()
.controlSize(.small)
Text("Generating waveform...")
.font(.system(size: 12))
Text("Loading...")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
HStack(spacing: 1) {
HStack(spacing: 0.5) {
ForEach(0..<samples.count, id: \.self) { index in
WaveformBar(
sample: samples[index],
@ -139,20 +139,21 @@ struct WaveformView: View {
)
}
}
.opacity(0.6)
.frame(maxHeight: .infinity)
.padding(.horizontal, 2)
if isHovering {
Text(formatTime(duration * Double(hoverLocation / geometry.size.width)))
.font(.system(size: 12, weight: .medium))
.font(.system(size: 10, weight: .medium))
.monospacedDigit()
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(Color.accentColor))
.offset(x: max(0, min(hoverLocation - 30, geometry.size.width - 60)))
.offset(y: -30)
.offset(x: max(0, min(hoverLocation - 25, geometry.size.width - 50)))
.offset(y: -26)
Rectangle()
.fill(Color.accentColor)
.frame(width: 2)
@ -186,7 +187,7 @@ struct WaveformView: View {
}
}
}
.frame(height: 56)
.frame(height: 32)
}
private func formatTime(_ time: TimeInterval) -> String {
@ -223,10 +224,10 @@ struct WaveformBar: View {
)
)
.frame(
width: max((geometryWidth / CGFloat(totalBars)) - 1, 1),
height: max(CGFloat(sample) * 40, 3)
width: max((geometryWidth / CGFloat(totalBars)) - 0.5, 1),
height: max(CGFloat(sample) * 24, 2)
)
.scaleEffect(y: isHovering && isNearHover ? 1.2 : 1.0)
.scaleEffect(y: isHovering && isNearHover ? 1.15 : 1.0)
.animation(.interpolatingSpring(stiffness: 300, damping: 15), value: isHovering && isNearHover)
}
}
@ -247,47 +248,38 @@ struct AudioPlayerView: View {
}
var body: some View {
VStack(spacing: 16) {
HStack {
HStack(spacing: 6) {
Image(systemName: "waveform")
.foregroundStyle(Color.accentColor)
Text("Recording")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.secondary)
Spacer()
Text(formatTime(playerManager.duration))
.font(.system(size: 14, weight: .medium))
VStack(spacing: 8) {
WaveformView(
samples: playerManager.waveformSamples,
currentTime: playerManager.currentTime,
duration: playerManager.duration,
isLoading: playerManager.isLoadingWaveform,
onSeek: { playerManager.seek(to: $0) }
)
.padding(.horizontal, 10)
HStack(spacing: 8) {
Text(formatTime(playerManager.currentTime))
.font(.system(size: 11, weight: .medium))
.monospacedDigit()
.foregroundColor(.secondary)
}
VStack(spacing: 16) {
WaveformView(
samples: playerManager.waveformSamples,
currentTime: playerManager.currentTime,
duration: playerManager.duration,
isLoading: playerManager.isLoadingWaveform,
onSeek: { playerManager.seek(to: $0) }
)
HStack(spacing: 20) {
Spacer()
HStack(spacing: 8) {
Button(action: showInFinder) {
Circle()
.fill(Color.orange.opacity(0.1))
.frame(width: 44, height: 44)
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "folder")
.font(.system(size: 18, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.orange)
)
}
.buttonStyle(.plain)
.help("Show in Finder")
Button(action: {
if playerManager.isPlaying {
playerManager.pause()
@ -297,10 +289,10 @@ struct AudioPlayerView: View {
}) {
Circle()
.fill(Color.accentColor.opacity(0.1))
.frame(width: 44, height: 44)
.frame(width: 32, height: 32)
.overlay(
Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.accentColor)
.contentTransition(.symbolEffect(.replace.downUp))
)
@ -312,11 +304,11 @@ struct AudioPlayerView: View {
isHovering = hovering
}
}
Button(action: retranscribeAudio) {
Circle()
.fill(Color.green.opacity(0.1))
.frame(width: 44, height: 44)
.frame(width: 32, height: 32)
.overlay(
Group {
if isRetranscribing {
@ -324,11 +316,11 @@ struct AudioPlayerView: View {
.controlSize(.small)
} else if showRetranscribeSuccess {
Image(systemName: "checkmark")
.font(.system(size: 18, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.green)
} else {
Image(systemName: "arrow.clockwise")
.font(.system(size: 18, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.green)
}
}
@ -337,16 +329,19 @@ struct AudioPlayerView: View {
.buttonStyle(.plain)
.disabled(isRetranscribing)
.help("Retranscribe this audio")
Text(formatTime(playerManager.currentTime))
.font(.system(size: 14, weight: .medium))
.monospacedDigit()
.foregroundColor(.secondary)
}
Spacer()
Text(formatTime(playerManager.duration))
.font(.system(size: 11, weight: .medium))
.monospacedDigit()
.foregroundColor(.secondary)
}
.padding(.horizontal, 10)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 6)
.onAppear {
playerManager.loadAudio(from: url)
}

View File

@ -0,0 +1,100 @@
import SwiftUI
struct TranscriptionDetailView: View {
let transcription: Transcription
private var hasAudioFile: Bool {
if let urlString = transcription.audioFileURL,
let url = URL(string: urlString),
FileManager.default.fileExists(atPath: url.path) {
return true
}
return false
}
var body: some View {
VStack(spacing: 12) {
ScrollView {
VStack(spacing: 16) {
MessageBubble(
label: "Original",
text: transcription.text,
isEnhanced: false
)
if let enhancedText = transcription.enhancedText {
MessageBubble(
label: "Enhanced",
text: enhancedText,
isEnhanced: true
)
}
}
.padding(16)
}
if hasAudioFile, let urlString = transcription.audioFileURL,
let url = URL(string: urlString) {
VStack(spacing: 0) {
Divider()
AudioPlayerView(url: url)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor).opacity(0.5))
)
.padding(.horizontal, 12)
.padding(.top, 6)
}
}
}
.padding(.vertical, 12)
.background(Color(NSColor.controlBackgroundColor))
}
}
private struct MessageBubble: View {
let label: String
let text: String
let isEnhanced: Bool
var body: some View {
HStack(alignment: .bottom) {
if isEnhanced { Spacer(minLength: 60) }
VStack(alignment: isEnhanced ? .leading : .trailing, spacing: 4) {
Text(label)
.font(.system(size: 9, weight: .medium))
.foregroundColor(.secondary.opacity(0.7))
.padding(.horizontal, 12)
ScrollView {
Text(text)
.font(.system(size: 14, weight: .regular))
.lineSpacing(2)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
.frame(maxHeight: 350)
.background {
if isEnhanced {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.accentColor.opacity(0.2))
} else {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.thinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5)
)
}
}
}
if !isEnhanced { Spacer(minLength: 60) }
}
}
}

View File

@ -0,0 +1,421 @@
import SwiftUI
import SwiftData
struct TranscriptionHistoryView: View {
@Environment(\.modelContext) private var modelContext
@State private var searchText = ""
@State private var selectedTranscription: Transcription?
@State private var selectedTranscriptions: Set<Transcription> = []
@State private var showDeleteConfirmation = false
@State private var isViewCurrentlyVisible = false
@State private var showAnalysisView = false
@State private var isLeftSidebarVisible = true
@State private var isRightSidebarVisible = true
@State private var leftSidebarWidth: CGFloat = 260
@State private var rightSidebarWidth: CGFloat = 260
@State private var displayedTranscriptions: [Transcription] = []
@State private var isLoading = false
@State private var hasMoreContent = true
@State private var lastTimestamp: Date?
private let exportService = VoiceInkCSVExportService()
private let minSidebarWidth: CGFloat = 200
private let maxSidebarWidth: CGFloat = 350
private let pageSize = 20
@Query(Self.createLatestTranscriptionIndicatorDescriptor()) private var latestTranscriptionIndicator: [Transcription]
private static func createLatestTranscriptionIndicatorDescriptor() -> FetchDescriptor<Transcription> {
var descriptor = FetchDescriptor<Transcription>(
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = 1
return descriptor
}
private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor<Transcription> {
var descriptor = FetchDescriptor<Transcription>(
sortBy: [SortDescriptor(\Transcription.timestamp, order: .reverse)]
)
if let timestamp = timestamp {
if !searchText.isEmpty {
descriptor.predicate = #Predicate<Transcription> { transcription in
(transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) &&
transcription.timestamp < timestamp
}
} else {
descriptor.predicate = #Predicate<Transcription> { transcription in
transcription.timestamp < timestamp
}
}
} else if !searchText.isEmpty {
descriptor.predicate = #Predicate<Transcription> { transcription in
transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)
}
}
descriptor.fetchLimit = pageSize
return descriptor
}
var body: some View {
HStack(spacing: 0) {
if isLeftSidebarVisible {
leftSidebarView
.frame(
minWidth: minSidebarWidth,
idealWidth: leftSidebarWidth,
maxWidth: maxSidebarWidth
)
.transition(.move(edge: .leading))
Divider()
}
centerPaneView
.frame(maxWidth: .infinity)
if isRightSidebarVisible {
Divider()
rightSidebarView
.frame(
minWidth: minSidebarWidth,
idealWidth: rightSidebarWidth,
maxWidth: maxSidebarWidth
)
.transition(.move(edge: .trailing))
}
}
.toolbar {
ToolbarItemGroup(placement: .navigation) {
Button(action: { withAnimation { isLeftSidebarVisible.toggle() } }) {
Label("Toggle Sidebar", systemImage: "sidebar.left")
}
}
ToolbarItemGroup(placement: .automatic) {
Button(action: { withAnimation { isRightSidebarVisible.toggle() } }) {
Label("Toggle Inspector", systemImage: "sidebar.right")
}
}
}
.alert("Delete Selected Items?", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
deleteSelectedTranscriptions()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This action cannot be undone. Are you sure you want to delete \(selectedTranscriptions.count) item\(selectedTranscriptions.count == 1 ? "" : "s")?")
}
.sheet(isPresented: $showAnalysisView) {
if !selectedTranscriptions.isEmpty {
PerformanceAnalysisView(transcriptions: Array(selectedTranscriptions))
}
}
.onAppear {
isViewCurrentlyVisible = true
Task {
await loadInitialContent()
}
}
.onDisappear {
isViewCurrentlyVisible = false
}
.onChange(of: searchText) { _, _ in
Task {
await resetPagination()
await loadInitialContent()
}
}
.onChange(of: latestTranscriptionIndicator.first?.id) { oldId, newId in
guard isViewCurrentlyVisible else { return }
if newId != oldId {
Task {
await resetPagination()
await loadInitialContent()
}
}
}
}
private var leftSidebarView: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 13))
TextField("Search transcriptions", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 13))
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.thinMaterial)
)
.padding(12)
Divider()
ZStack(alignment: .bottom) {
if displayedTranscriptions.isEmpty && !isLoading {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("No transcriptions")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(displayedTranscriptions) { transcription in
TranscriptionListItem(
transcription: transcription,
isSelected: selectedTranscription == transcription,
isChecked: selectedTranscriptions.contains(transcription),
onSelect: { selectedTranscription = transcription },
onToggleCheck: { toggleSelection(transcription) }
)
}
if hasMoreContent {
Button(action: {
Task { await loadMoreContent() }
}) {
HStack(spacing: 8) {
if isLoading {
ProgressView().controlSize(.small)
}
Text(isLoading ? "Loading..." : "Load More")
.font(.system(size: 13, weight: .medium))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
.disabled(isLoading)
}
}
.padding(8)
.padding(.bottom, !selectedTranscriptions.isEmpty ? 50 : 0)
}
}
if !selectedTranscriptions.isEmpty {
selectionToolbar
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.background(Color(NSColor.controlBackgroundColor))
}
private var centerPaneView: some View {
Group {
if let transcription = selectedTranscription {
TranscriptionDetailView(transcription: transcription)
.id(transcription.id)
} else {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No Selection")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.secondary)
Text("Select a transcription to view details")
.font(.system(size: 14))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.controlBackgroundColor))
}
}
}
private var rightSidebarView: some View {
Group {
if let transcription = selectedTranscription {
TranscriptionMetadataView(transcription: transcription)
.id(transcription.id)
} else {
VStack(spacing: 12) {
Image(systemName: "info.circle")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("No Metadata")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.controlBackgroundColor))
}
}
}
private var selectionToolbar: some View {
HStack(spacing: 12) {
Button(action: { showAnalysisView = true }) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 14, weight: .regular))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("Analyze")
Button(action: {
exportService.exportTranscriptionsToCSV(transcriptions: Array(selectedTranscriptions))
}) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14, weight: .regular))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("Export")
Button(action: { showDeleteConfirmation = true }) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .regular))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("Delete")
Spacer()
Text("\(selectedTranscriptions.count) selected")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Color(NSColor.windowBackgroundColor)
.shadow(color: Color.black.opacity(0.15), radius: 3, y: -2)
)
}
@MainActor
private func loadInitialContent() async {
isLoading = true
defer { isLoading = false }
do {
lastTimestamp = nil
let items = try modelContext.fetch(cursorQueryDescriptor())
displayedTranscriptions = items
lastTimestamp = items.last?.timestamp
hasMoreContent = items.count == pageSize
} catch {
print("Error loading transcriptions: \(error)")
}
}
@MainActor
private func loadMoreContent() async {
guard !isLoading, hasMoreContent, let lastTimestamp = lastTimestamp else { return }
isLoading = true
defer { isLoading = false }
do {
let newItems = try modelContext.fetch(cursorQueryDescriptor(after: lastTimestamp))
displayedTranscriptions.append(contentsOf: newItems)
self.lastTimestamp = newItems.last?.timestamp
hasMoreContent = newItems.count == pageSize
} catch {
print("Error loading more transcriptions: \(error)")
}
}
@MainActor
private func resetPagination() {
displayedTranscriptions = []
lastTimestamp = nil
hasMoreContent = true
isLoading = false
}
private func deleteTranscription(_ transcription: Transcription) {
if let urlString = transcription.audioFileURL,
let url = URL(string: urlString) {
try? FileManager.default.removeItem(at: url)
}
modelContext.delete(transcription)
if selectedTranscription == transcription {
selectedTranscription = nil
}
selectedTranscriptions.remove(transcription)
Task {
try? modelContext.save()
await loadInitialContent()
}
}
private func deleteSelectedTranscriptions() {
for transcription in selectedTranscriptions {
if let urlString = transcription.audioFileURL,
let url = URL(string: urlString) {
try? FileManager.default.removeItem(at: url)
}
modelContext.delete(transcription)
if selectedTranscription == transcription {
selectedTranscription = nil
}
}
selectedTranscriptions.removeAll()
Task {
try? modelContext.save()
await loadInitialContent()
}
}
private func toggleSelection(_ transcription: Transcription) {
if selectedTranscriptions.contains(transcription) {
selectedTranscriptions.remove(transcription)
} else {
selectedTranscriptions.insert(transcription)
}
}
private func selectAllTranscriptions() async {
do {
var allDescriptor = FetchDescriptor<Transcription>()
if !searchText.isEmpty {
allDescriptor.predicate = #Predicate<Transcription> { transcription in
transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)
}
}
allDescriptor.propertiesToFetch = [\.id]
let allTranscriptions = try modelContext.fetch(allDescriptor)
let visibleIds = Set(displayedTranscriptions.map { $0.id })
await MainActor.run {
selectedTranscriptions = Set(displayedTranscriptions)
for transcription in allTranscriptions {
if !visibleIds.contains(transcription.id) {
selectedTranscriptions.insert(transcription)
}
}
}
} catch {
print("Error selecting all transcriptions: \(error)")
}
}
}

View File

@ -0,0 +1,83 @@
import SwiftUI
struct TranscriptionListItem: View {
let transcription: Transcription
let isSelected: Bool
let isChecked: Bool
let onSelect: () -> Void
let onToggleCheck: () -> Void
var body: some View {
HStack(spacing: 8) {
Toggle("", isOn: Binding(
get: { isChecked },
set: { _ in onToggleCheck() }
))
.toggleStyle(CircularCheckboxStyle())
.labelsHidden()
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(transcription.timestamp, format: .dateTime.month(.abbreviated).day().hour().minute())
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
Spacer()
if transcription.duration > 0 {
Text(formatTiming(transcription.duration))
.font(.system(size: 10, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color.secondary.opacity(0.1))
)
.foregroundColor(.secondary)
}
}
Text(transcription.enhancedText ?? transcription.text)
.font(.system(size: 12, weight: .regular))
.lineLimit(2)
.foregroundColor(.primary)
}
}
.padding(10)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(NSColor.selectedContentBackgroundColor).opacity(0.3))
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.thinMaterial)
}
}
.contentShape(Rectangle())
.onTapGesture { onSelect() }
}
private func formatTiming(_ duration: TimeInterval) -> String {
if duration < 1 {
return String(format: "%.0fms", duration * 1000)
}
if duration < 60 {
return String(format: "%.1fs", duration)
}
let minutes = Int(duration) / 60
let seconds = duration.truncatingRemainder(dividingBy: 60)
return String(format: "%dm %.0fs", minutes, seconds)
}
}
struct CircularCheckboxStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button(action: {
configuration.isOn.toggle()
}) {
Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
.symbolRenderingMode(.hierarchical)
.foregroundColor(configuration.isOn ? Color(NSColor.controlAccentColor) : .secondary)
.font(.system(size: 18))
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,184 @@
import SwiftUI
struct TranscriptionMetadataView: View {
let transcription: Transcription
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Details")
.font(.system(size: 14, weight: .semibold))
VStack(alignment: .leading, spacing: 8) {
metadataRow(
icon: "calendar",
label: "Date",
value: transcription.timestamp.formatted(date: .abbreviated, time: .shortened)
)
Divider()
metadataRow(
icon: "hourglass",
label: "Duration",
value: formatTiming(transcription.duration)
)
if let modelName = transcription.transcriptionModelName {
Divider()
metadataRow(
icon: "cpu.fill",
label: "Transcription Model",
value: modelName
)
if let duration = transcription.transcriptionDuration {
Divider()
metadataRow(
icon: "clock.fill",
label: "Transcription Time",
value: formatTiming(duration)
)
}
}
if let aiModel = transcription.aiEnhancementModelName {
Divider()
metadataRow(
icon: "sparkles",
label: "Enhancement Model",
value: aiModel
)
if let duration = transcription.enhancementDuration {
Divider()
metadataRow(
icon: "clock.fill",
label: "Enhancement Time",
value: formatTiming(duration)
)
}
}
if let promptName = transcription.promptName {
Divider()
metadataRow(
icon: "text.bubble.fill",
label: "Prompt",
value: promptName
)
}
if let powerModeValue = powerModeDisplay(
name: transcription.powerModeName,
emoji: transcription.powerModeEmoji
) {
Divider()
metadataRow(
icon: "bolt.fill",
label: "Power Mode",
value: powerModeValue
)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.thinMaterial)
)
if transcription.aiRequestSystemMessage != nil || transcription.aiRequestUserMessage != nil {
VStack(alignment: .leading, spacing: 12) {
Text("AI Request")
.font(.system(size: 14, weight: .semibold))
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let systemMsg = transcription.aiRequestSystemMessage, !systemMsg.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("System Prompt")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Text(systemMsg)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.lineSpacing(2)
.textSelection(.enabled)
.foregroundColor(.primary)
}
}
if let userMsg = transcription.aiRequestUserMessage, !userMsg.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("User Message")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Text(userMsg)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.lineSpacing(2)
.textSelection(.enabled)
.foregroundColor(.primary)
}
}
}
.padding(14)
}
.frame(minHeight: 250, maxHeight: 500)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.thinMaterial)
)
}
}
}
.padding(12)
}
.background(Color(NSColor.controlBackgroundColor))
}
private func metadataRow(icon: String, label: String, value: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Spacer(minLength: 0)
Text(value)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.primary)
.lineLimit(1)
}
}
private func formatTiming(_ duration: TimeInterval) -> String {
if duration < 1 {
return String(format: "%.0fms", duration * 1000)
}
if duration < 60 {
return String(format: "%.1fs", duration)
}
let minutes = Int(duration) / 60
let seconds = duration.truncatingRemainder(dividingBy: 60)
return String(format: "%dm %.0fs", minutes, seconds)
}
private func powerModeDisplay(name: String?, emoji: String?) -> String? {
guard name != nil || emoji != nil else { return nil }
switch (emoji?.trimmingCharacters(in: .whitespacesAndNewlines), name?.trimmingCharacters(in: .whitespacesAndNewlines)) {
case let (.some(emojiValue), .some(nameValue)) where !emojiValue.isEmpty && !nameValue.isEmpty:
return "\(emojiValue) \(nameValue)"
case let (.some(emojiValue), _) where !emojiValue.isEmpty:
return emojiValue
case let (_, .some(nameValue)) where !nameValue.isEmpty:
return nameValue
default:
return nil
}
}
}

View File

@ -1,442 +0,0 @@
import SwiftUI
import SwiftData
struct TranscriptionHistoryView: View {
@Environment(\.modelContext) private var modelContext
@State private var searchText = ""
@State private var expandedTranscription: Transcription?
@State private var selectedTranscriptions: Set<Transcription> = []
@State private var showDeleteConfirmation = false
@State private var isViewCurrentlyVisible = false
@State private var showAnalysisView = false
private let exportService = VoiceInkCSVExportService()
// Pagination states
@State private var displayedTranscriptions: [Transcription] = []
@State private var isLoading = false
@State private var hasMoreContent = true
// Cursor-based pagination - track the last timestamp
@State private var lastTimestamp: Date?
private let pageSize = 20
@Query(Self.createLatestTranscriptionIndicatorDescriptor()) private var latestTranscriptionIndicator: [Transcription]
// Static function to create the FetchDescriptor for the latest transcription indicator
private static func createLatestTranscriptionIndicatorDescriptor() -> FetchDescriptor<Transcription> {
var descriptor = FetchDescriptor<Transcription>(
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = 1
return descriptor
}
// Cursor-based query descriptor
private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor<Transcription> {
var descriptor = FetchDescriptor<Transcription>(
sortBy: [SortDescriptor(\Transcription.timestamp, order: .reverse)]
)
// Build the predicate based on search text and timestamp cursor
if let timestamp = timestamp {
if !searchText.isEmpty {
descriptor.predicate = #Predicate<Transcription> { transcription in
(transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) &&
transcription.timestamp < timestamp
}
} else {
descriptor.predicate = #Predicate<Transcription> { transcription in
transcription.timestamp < timestamp
}
}
} else if !searchText.isEmpty {
descriptor.predicate = #Predicate<Transcription> { transcription in
transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)
}
}
descriptor.fetchLimit = pageSize
return descriptor
}
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
searchBar
if displayedTranscriptions.isEmpty && !isLoading {
emptyStateView
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 10) {
ForEach(displayedTranscriptions) { transcription in
TranscriptionCard(
transcription: transcription,
isExpanded: expandedTranscription == transcription,
isSelected: selectedTranscriptions.contains(transcription),
onDelete: { deleteTranscription(transcription) },
onToggleSelection: { toggleSelection(transcription) }
)
.id(transcription) // Using the object as its own ID
.onTapGesture {
withAnimation(.easeInOut(duration: 0.25)) {
if expandedTranscription == transcription {
expandedTranscription = nil
} else {
expandedTranscription = transcription
}
}
}
}
if hasMoreContent {
Button(action: {
Task {
await loadMoreContent()
}
}) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.controlSize(.small)
}
Text(isLoading ? "Loading..." : "Load More")
.font(.system(size: 14, weight: .medium))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardBackground(isSelected: false))
}
.buttonStyle(.plain)
.disabled(isLoading)
.padding(.top, 12)
}
}
.animation(.easeInOut(duration: 0.3), value: expandedTranscription)
.padding(24)
// Add bottom padding to ensure content is not hidden by the toolbar when visible
.padding(.bottom, !selectedTranscriptions.isEmpty ? 60 : 0)
}
.padding(.vertical, 16)
.onChange(of: expandedTranscription) { old, new in
if let transcription = new {
proxy.scrollTo(transcription, anchor: nil)
}
}
}
}
}
.background(Color(NSColor.controlBackgroundColor))
// Selection toolbar as an overlay
if !selectedTranscriptions.isEmpty {
selectionToolbar
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut(duration: 0.3), value: !selectedTranscriptions.isEmpty)
}
}
.alert("Delete Selected Items?", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
deleteSelectedTranscriptions()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This action cannot be undone. Are you sure you want to delete \(selectedTranscriptions.count) item\(selectedTranscriptions.count == 1 ? "" : "s")?")
}
.sheet(isPresented: $showAnalysisView) {
if !selectedTranscriptions.isEmpty {
PerformanceAnalysisView(transcriptions: Array(selectedTranscriptions))
}
}
.onAppear {
isViewCurrentlyVisible = true
Task {
await loadInitialContent()
}
}
.onDisappear {
isViewCurrentlyVisible = false
}
.onChange(of: searchText) { _, _ in
Task {
await resetPagination()
await loadInitialContent()
}
}
// Improved change detection for new transcriptions
.onChange(of: latestTranscriptionIndicator.first?.id) { oldId, newId in
guard isViewCurrentlyVisible else { return } // Only proceed if the view is visible
// Check if a new transcription was added or the latest one changed
if newId != oldId {
// Only refresh if we're on the first page (no pagination cursor set)
// or if the view is active and new content is relevant.
if lastTimestamp == nil {
Task {
await resetPagination()
await loadInitialContent()
}
} else {
// Reset pagination to show the latest content
Task {
await resetPagination()
await loadInitialContent()
}
}
}
}
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search transcriptions", text: $searchText)
.font(.system(size: 16, weight: .regular, design: .default))
.textFieldStyle(PlainTextFieldStyle())
}
.padding(12)
.background(CardBackground(isSelected: false))
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No transcriptions found")
.font(.system(size: 24, weight: .semibold, design: .default))
Text("Your history will appear here")
.font(.system(size: 18, weight: .regular, design: .default))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(CardBackground(isSelected: false))
.padding(24)
}
private var selectionToolbar: some View {
HStack(spacing: 12) {
Text("\(selectedTranscriptions.count) selected")
.foregroundColor(.secondary)
.font(.system(size: 14))
Spacer()
Button(action: {
showAnalysisView = true
}) {
HStack(spacing: 4) {
Image(systemName: "chart.bar.xaxis")
Text("Analyze")
}
}
.buttonStyle(.borderless)
Button(action: {
exportService.exportTranscriptionsToCSV(transcriptions: Array(selectedTranscriptions))
}) {
HStack(spacing: 4) {
Image(systemName: "square.and.arrow.up")
Text("Export")
}
}
.buttonStyle(.borderless)
Button(action: {
showDeleteConfirmation = true
}) {
HStack(spacing: 4) {
Image(systemName: "trash")
Text("Delete")
}
}
.buttonStyle(.borderless)
if selectedTranscriptions.count < displayedTranscriptions.count {
Button("Select All") {
Task {
await selectAllTranscriptions()
}
}
.buttonStyle(.borderless)
} else {
Button("Deselect All") {
selectedTranscriptions.removeAll()
}
.buttonStyle(.borderless)
}
}
.padding(16)
.frame(maxWidth: .infinity)
.background(
Color(.windowBackgroundColor)
.shadow(color: Color.black.opacity(0.1), radius: 3, y: -2)
)
}
@MainActor
private func loadInitialContent() async {
isLoading = true
defer { isLoading = false }
do {
// Reset cursor
lastTimestamp = nil
// Fetch initial page without a cursor
let items = try modelContext.fetch(cursorQueryDescriptor())
displayedTranscriptions = items
// Update cursor to the timestamp of the last item
lastTimestamp = items.last?.timestamp
// If we got fewer items than the page size, there are no more items
hasMoreContent = items.count == pageSize
} catch {
print("Error loading transcriptions: \(error)")
}
}
@MainActor
private func loadMoreContent() async {
guard !isLoading, hasMoreContent, let lastTimestamp = lastTimestamp else { return }
isLoading = true
defer { isLoading = false }
do {
// Fetch next page using the cursor
let newItems = try modelContext.fetch(cursorQueryDescriptor(after: lastTimestamp))
// Append new items to the displayed list
displayedTranscriptions.append(contentsOf: newItems)
// Update cursor to the timestamp of the last new item
self.lastTimestamp = newItems.last?.timestamp
// If we got fewer items than the page size, there are no more items
hasMoreContent = newItems.count == pageSize
} catch {
print("Error loading more transcriptions: \(error)")
}
}
@MainActor
private func resetPagination() {
displayedTranscriptions = []
lastTimestamp = nil
hasMoreContent = true
isLoading = false
}
private func deleteTranscription(_ transcription: Transcription) {
// First delete the audio file if it exists
if let urlString = transcription.audioFileURL,
let url = URL(string: urlString) {
try? FileManager.default.removeItem(at: url)
}
modelContext.delete(transcription)
if expandedTranscription == transcription {
expandedTranscription = nil
}
// Remove from selection if selected
selectedTranscriptions.remove(transcription)
// Refresh the view
Task {
try? await modelContext.save()
await loadInitialContent()
}
}
private func deleteSelectedTranscriptions() {
// Delete audio files and transcriptions
for transcription in selectedTranscriptions {
if let urlString = transcription.audioFileURL,
let url = URL(string: urlString) {
try? FileManager.default.removeItem(at: url)
}
modelContext.delete(transcription)
if expandedTranscription == transcription {
expandedTranscription = nil
}
}
// Clear selection
selectedTranscriptions.removeAll()
// Save changes and refresh
Task {
try? await modelContext.save()
await loadInitialContent()
}
}
private func toggleSelection(_ transcription: Transcription) {
if selectedTranscriptions.contains(transcription) {
selectedTranscriptions.remove(transcription)
} else {
selectedTranscriptions.insert(transcription)
}
}
// Modified function to select all transcriptions in the database
private func selectAllTranscriptions() async {
do {
// Create a descriptor without pagination limits to get all IDs
var allDescriptor = FetchDescriptor<Transcription>()
// Apply search filter if needed
if !searchText.isEmpty {
allDescriptor.predicate = #Predicate<Transcription> { transcription in
transcription.text.localizedStandardContains(searchText) ||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)
}
}
// For better performance, only fetch the IDs
allDescriptor.propertiesToFetch = [\.id]
// Fetch all matching transcriptions
let allTranscriptions = try modelContext.fetch(allDescriptor)
// Create a set of all visible transcriptions for quick lookup
let visibleIds = Set(displayedTranscriptions.map { $0.id })
// Add all transcriptions to the selection
await MainActor.run {
// First add all visible transcriptions directly
selectedTranscriptions = Set(displayedTranscriptions)
// Then add any non-visible transcriptions by ID
for transcription in allTranscriptions {
if !visibleIds.contains(transcription.id) {
selectedTranscriptions.insert(transcription)
}
}
}
} catch {
print("Error selecting all transcriptions: \(error)")
}
}
}
struct CircularCheckboxStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button(action: {
configuration.isOn.toggle()
}) {
Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
.symbolRenderingMode(.hierarchical)
.foregroundColor(configuration.isOn ? .blue : .gray)
.font(.system(size: 18))
}
.buttonStyle(.plain)
}
}