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:
2026-02-15 23:23:08 +01:00
parent edb4b8a567
commit 05cc93f3c3
2 changed files with 40 additions and 59 deletions

View File

@@ -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
)
}
}

View File

@@ -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)
}