EnregistrementNoteViewModel.swift
198 lignes · 7277 octets
import AVFoundation import Foundation import Speech /// Phases du cycle de vie de la feuille d'enregistrement. enum PhaseEnregistrement: Equatable { case demandePermission case permissionMicRefusee case permissionSpeechRefusee case pret case enregistrement case edition case sauvegarde case termine(message: String) } /// ViewModel de la feuille d'enregistrement de note vocale. /// /// Commun à la fiche contact (interaction `note_vocale`) et à la section Notes du projet /// (`NoteProjetEntity`). Le contexte de sauvegarde est injecté via `sauvegarder`. /// /// Les méthodes statiques `sujetParDefaut` et `messageConfirmation` sont pures et /// testables sans accès au matériel audio. @MainActor final class EnregistrementNoteViewModel: ObservableObject { @Published private(set) var phase: PhaseEnregistrement = .demandePermission @Published private(set) var dureeSecondes: Int = 0 @Published private(set) var transcriptionEnDirect: String = "" @Published var sujet: String = "" @Published var descriptionNote: String = "" let modeTranscription: ModeTranscription private let recorder: AudioNoteRecorder private let transcripteurFactory: () -> (any TranscripteurLocal)? private var transcripteurActif: (any TranscripteurLocal)? private var audioPathTemp: URL? private var timerChrono: Timer? private let sauvegarderAction: (String, String, String, Bool) async -> Bool /// - Parameters: /// - modeTranscription: mode persisté ou `.serveur` si SFSpeechRecognizer indisponible. /// - recorder: injecteur de moteur audio (faux en tests). /// - transcripteurFactory: crée le transcripteur à l'enregistrement (faux en tests). /// - sauvegarder: `(sujet, description, audioPath, demandeTranscription) async -> envoye`. /// Retourne `true` si l'audio a été effectivement envoyé au serveur (push immédiat OK). init( modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription, recorder: AudioNoteRecorder = AudioNoteRecorder(), transcripteurFactory: @escaping () -> (any TranscripteurLocal)? = { TranscripteurSpeech() }, sauvegarder: @escaping (String, String, String, Bool) async -> Bool ) { self.modeTranscription = modeTranscription self.recorder = recorder self.transcripteurFactory = transcripteurFactory self.sauvegarderAction = sauvegarder } // MARK: - Fonctions pures (testables) /// Génère le sujet par défaut : « Notes du jj/MM/yyyy à HH:mm ». nonisolated static func sujetParDefaut(maintenant: Date = Date()) -> String { let f = DateFormatter() f.dateFormat = "dd/MM/yyyy 'à' HH:mm" f.locale = Locale(identifier: "fr_FR") return "Notes du \(f.string(from: maintenant))" } /// Message de confirmation adapté au mode et au résultat d'envoi. nonisolated static func messageConfirmation(mode: ModeTranscription, envoye: Bool) -> String { switch mode { case .appareil: return "Note vocale enregistrée" case .serveur: return envoye ? "Note vocale envoyée au serveur — la transcription est en cours" : "Note vocale enregistrée — elle sera envoyée à la prochaine synchronisation" } } // MARK: - Durée formatée mm:ss var dureeFormatee: String { String(format: "%02d:%02d", dureeSecondes / 60, dureeSecondes % 60) } // MARK: - Permissions func demanderPermissions() async { // Microphone let micOK: Bool if #available(iOS 17.0, *) { micOK = await AVAudioApplication.requestRecordPermission() } else { micOK = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) } } } guard micOK else { phase = .permissionMicRefusee return } // Reconnaissance vocale (seulement en mode appareil) if modeTranscription == .appareil { let speechOK = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in SFSpeechRecognizer.requestAuthorization { cont.resume(returning: $0 == .authorized) } } guard speechOK else { phase = .permissionSpeechRefusee return } } phase = .pret } // MARK: - Enregistrement func demarrer() { let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent("note_vocale_\(UUID().uuidString).wav") audioPathTemp = tempURL let t: (any TranscripteurLocal)? = (modeTranscription == .appareil) ? transcripteurFactory() : nil transcripteurActif = t do { try recorder.demarrer(fichierCible: tempURL, transcripteur: t) } catch { phase = .permissionMicRefusee return } phase = .enregistrement dureeSecondes = 0 timerChrono = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in guard let self, case .enregistrement = self.phase else { return } self.dureeSecondes += 1 // Mise à jour du texte partiel en mode appareil if let partial = self.transcripteurActif?.accepterEchantillons([]), !partial.isEmpty { self.transcriptionEnDirect = partial } } } } func arreter() { timerChrono?.invalidate() timerChrono = nil _ = recorder.arreter() // Récupère le texte final de transcription if let t = transcripteurActif { let final = t.finaliser() if !final.isEmpty { descriptionNote = final } } transcripteurActif = nil sujet = Self.sujetParDefaut() phase = .edition } // MARK: - Sauvegarde / annulation func sauvegarderNote() { guard case .edition = phase, let audioURL = audioPathTemp else { return } phase = .sauvegarde let s = sujet.trimmingCharacters(in: .whitespacesAndNewlines) let finalSujet = s.isEmpty ? Self.sujetParDefaut() : s let d = descriptionNote let path = audioURL.path let demandeTranscription = (modeTranscription == .serveur) Task { let envoye = await sauvegarderAction(finalSujet, d, path, demandeTranscription) phase = .termine(message: Self.messageConfirmation(mode: modeTranscription, envoye: envoye)) } } /// Annule l'enregistrement : arrête le moteur et supprime le fichier temporaire. func annuler() { timerChrono?.invalidate() timerChrono = nil if recorder.estEnCours { _ = recorder.arreter() } if let tmp = audioPathTemp { try? FileManager.default.removeItem(at: tmp) audioPathTemp = nil } } }
GitRust