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