406 lines
16 KiB
Swift
406 lines
16 KiB
Swift
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
|
|
|
|
// 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
|
|
|
|
// New query: Fetches only the ID of the most recent transcription to minimize data loading.
|
|
// Renamed to latestTranscriptionIndicator to reflect its purpose.
|
|
@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 {
|
|
ScrollView {
|
|
LazyVStack(spacing: 10) {
|
|
ForEach(displayedTranscriptions) { transcription in
|
|
TranscriptionCard(
|
|
transcription: transcription,
|
|
isExpanded: expandedTranscription == transcription,
|
|
isSelected: selectedTranscriptions.contains(transcription),
|
|
onDelete: { deleteTranscription(transcription) },
|
|
onToggleSelection: { toggleSelection(transcription) }
|
|
)
|
|
.onTapGesture {
|
|
expandedTranscription = expandedTranscription == transcription ? nil : transcription
|
|
}
|
|
}
|
|
|
|
if hasMoreContent {
|
|
Button(action: {
|
|
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(Color(.windowBackgroundColor).opacity(0.4))
|
|
.cornerRadius(8)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isLoading)
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.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")?")
|
|
}
|
|
.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 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(Color(.windowBackgroundColor).opacity(0.4))
|
|
.cornerRadius(10)
|
|
.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(Color(.windowBackgroundColor).opacity(0.4))
|
|
.padding(24)
|
|
}
|
|
|
|
private var selectionToolbar: some View {
|
|
HStack(spacing: 12) {
|
|
Text("\(selectedTranscriptions.count) selected")
|
|
.foregroundColor(.secondary)
|
|
.font(.system(size: 14))
|
|
|
|
Spacer()
|
|
|
|
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)
|
|
)
|
|
}
|
|
|
|
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())
|
|
|
|
await MainActor.run {
|
|
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)")
|
|
}
|
|
}
|
|
|
|
private func loadMoreContent() {
|
|
guard !isLoading, hasMoreContent, let lastTimestamp = lastTimestamp else { return }
|
|
|
|
Task {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
// Fetch next page using the cursor
|
|
let newItems = try modelContext.fetch(cursorQueryDescriptor(after: lastTimestamp))
|
|
|
|
await MainActor.run {
|
|
// 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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resetPagination() async {
|
|
await MainActor.run {
|
|
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)
|
|
}
|
|
}
|