fix dispatch_assert_queue crash: move SwiftData ops out of ViewModel
SwiftData's ModelContext has internal queue assertions that conflict with @MainActor @Observable ViewModels. Move all context.fetch/insert operations into the SwiftUI View body (which runs on the correct queue). ViewModel now only handles audio recording state, View handles persistence. Removed transcription from stop flow for now. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,9 @@ import SwiftData
|
||||
final class RecordingViewModel {
|
||||
var isRecording = false
|
||||
var error: Error?
|
||||
var lastRecordedFile: (url: URL, fileName: String, duration: TimeInterval)?
|
||||
|
||||
private let recorder = AudioRecorderService()
|
||||
private let transcriptionService = TranscriptionService()
|
||||
|
||||
var formattedDuration: String {
|
||||
let minutes = Int(recorder.recordingDuration) / 60
|
||||
@@ -22,71 +22,22 @@ final class RecordingViewModel {
|
||||
_ = try recorder.startRecording()
|
||||
isRecording = true
|
||||
error = nil
|
||||
lastRecordedFile = nil
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(context: ModelContext) {
|
||||
func stopRecording() {
|
||||
guard let result = recorder.stopRecording() else {
|
||||
isRecording = false
|
||||
return
|
||||
}
|
||||
isRecording = false
|
||||
|
||||
let fileName = result.url.lastPathComponent
|
||||
let duration = result.duration
|
||||
|
||||
let entry: DiaryEntry
|
||||
do {
|
||||
entry = try fetchOrCreateEntry(for: .now, context: context)
|
||||
} catch {
|
||||
self.error = error
|
||||
return
|
||||
}
|
||||
|
||||
let memo = VoiceMemo(audioFileName: fileName, duration: duration)
|
||||
memo.entry = entry
|
||||
context.insert(memo)
|
||||
|
||||
let audioURL = memo.audioURL
|
||||
Task {
|
||||
await transcribeMemo(memo, audioURL: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchOrCreateEntry(for date: Date, context: ModelContext) throws -> DiaryEntry {
|
||||
let startOfDay = Calendar.current.startOfDay(for: date)
|
||||
let descriptor = FetchDescriptor<DiaryEntry>()
|
||||
let entries = try context.fetch(descriptor)
|
||||
|
||||
if let match = entries.first(where: { Calendar.current.isDate($0.date, inSameDayAs: startOfDay) }) {
|
||||
return match
|
||||
}
|
||||
|
||||
let entry = DiaryEntry(date: startOfDay)
|
||||
context.insert(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
private func transcribeMemo(_ memo: VoiceMemo, audioURL: URL) async {
|
||||
if transcriptionService.authorizationStatus == .notDetermined {
|
||||
await transcriptionService.requestAuthorization()
|
||||
}
|
||||
|
||||
guard transcriptionService.authorizationStatus == .authorized else {
|
||||
return
|
||||
}
|
||||
|
||||
memo.isTranscribing = true
|
||||
|
||||
do {
|
||||
let transcript = try await transcriptionService.transcribe(audioURL: audioURL)
|
||||
memo.transcript = transcript
|
||||
memo.isTranscribing = false
|
||||
} catch {
|
||||
memo.isTranscribing = false
|
||||
self.error = error
|
||||
}
|
||||
lastRecordedFile = (
|
||||
url: result.url,
|
||||
fileName: result.url.lastPathComponent,
|
||||
duration: result.duration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct RecordingView: View {
|
||||
@@ -31,8 +32,9 @@ struct RecordingView: View {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "general.done")) {
|
||||
if viewModel.isRecording {
|
||||
viewModel.stopRecording(context: modelContext)
|
||||
viewModel.stopRecording()
|
||||
}
|
||||
saveRecordingIfNeeded()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -52,7 +54,8 @@ struct RecordingView: View {
|
||||
private var recordButton: some View {
|
||||
Button {
|
||||
if viewModel.isRecording {
|
||||
viewModel.stopRecording(context: modelContext)
|
||||
viewModel.stopRecording()
|
||||
saveRecordingIfNeeded()
|
||||
} else {
|
||||
viewModel.startRecording()
|
||||
}
|
||||
@@ -80,8 +83,35 @@ struct RecordingView: View {
|
||||
)
|
||||
.sensoryFeedback(.impact, trigger: viewModel.isRecording)
|
||||
}
|
||||
|
||||
private func saveRecordingIfNeeded() {
|
||||
guard let recorded = viewModel.lastRecordedFile else { return }
|
||||
|
||||
let today = Calendar.current.startOfDay(for: .now)
|
||||
let entry = fetchOrCreateEntry(for: today)
|
||||
|
||||
let memo = VoiceMemo(audioFileName: recorded.fileName, duration: recorded.duration)
|
||||
memo.entry = entry
|
||||
modelContext.insert(memo)
|
||||
|
||||
viewModel.lastRecordedFile = nil
|
||||
}
|
||||
|
||||
private func fetchOrCreateEntry(for date: Date) -> DiaryEntry {
|
||||
let descriptor = FetchDescriptor<DiaryEntry>()
|
||||
let entries = (try? modelContext.fetch(descriptor)) ?? []
|
||||
|
||||
if let match = entries.first(where: { Calendar.current.isDate($0.date, inSameDayAs: date) }) {
|
||||
return match
|
||||
}
|
||||
|
||||
let entry = DiaryEntry(date: date)
|
||||
modelContext.insert(entry)
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RecordingView(viewModel: RecordingViewModel())
|
||||
.modelContainer(for: [DiaryEntry.self, VoiceMemo.self], inMemory: true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user