- Make TranscriptionService a plain Sendable class (not @Observable/@MainActor) - Request speech authorization in ContentView.onAppear via callback (no async) - Use @State pendingMemo + Task in View for transcription (Swift 6 safe) - Separate saveRecording() and startTranscription() to avoid data races Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
120 lines
2.7 KiB
Swift
120 lines
2.7 KiB
Swift
import SwiftData
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Query(sort: \DiaryEntry.date, order: .reverse) private var entries: [DiaryEntry]
|
|
@State private var recordingViewModel = RecordingViewModel()
|
|
@State private var diaryViewModel = DiaryViewModel()
|
|
@State private var showingRecording = false
|
|
@State private var speechAuthorized = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if entries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
diaryList
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "diary.title"))
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showingRecording = true
|
|
} label: {
|
|
Image(systemName: "mic.circle.fill")
|
|
.font(.title2)
|
|
}
|
|
.accessibilityLabel(String(localized: "recording.start"))
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingRecording) {
|
|
RecordingView(viewModel: recordingViewModel)
|
|
}
|
|
}
|
|
.environment(diaryViewModel)
|
|
.onAppear {
|
|
SpeechAuthorization.requestIfNeeded { authorized in
|
|
speechAuthorized = authorized
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "diary.empty.title"), systemImage: "book.closed")
|
|
} description: {
|
|
Text(String(localized: "diary.empty.description"))
|
|
} actions: {
|
|
Button {
|
|
showingRecording = true
|
|
} label: {
|
|
Text(String(localized: "diary.empty.action"))
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
private var diaryList: some View {
|
|
List {
|
|
ForEach(entries) { entry in
|
|
NavigationLink(value: entry) {
|
|
DiaryListRow(entry: entry)
|
|
}
|
|
}
|
|
.onDelete { indexSet in
|
|
for index in indexSet {
|
|
diaryViewModel.deleteEntry(entries[index], context: modelContext)
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(for: DiaryEntry.self) { entry in
|
|
DiaryEntryView(entry: entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DiaryListRow: View {
|
|
let entry: DiaryEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(entry.formattedDate)
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 12) {
|
|
Label(
|
|
String(localized: "diary.memoCount \(entry.allMemos.count)"),
|
|
systemImage: "waveform"
|
|
)
|
|
|
|
if entry.isSummaryGenerated {
|
|
Label(
|
|
String(localized: "diary.summarized"),
|
|
systemImage: "checkmark.circle"
|
|
)
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let summary = entry.summary {
|
|
Text(summary.prefix(120))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.modelContainer(for: DiaryEntry.self, inMemory: true)
|
|
}
|