NoteVocaleRow.swift
143 lignes · 5195 octets
import AVFoundation import SwiftUI /// Ligne d'affichage d'une note vocale (interaction `note_vocale` ou `NoteProjetEntity`). /// /// Affiche : sujet, description/transcription, badge de statut (`en_attente` / `echec`) /// avec le motif d'erreur quand il existe, lecture audio si le fichier est présent localement, /// et les actions « Relancer la transcription » (echec uniquement) et « Supprimer ». struct NoteVocaleRow: View { let sujet: String let description: String let audioPath: String? let transcriptionStatut: String? let transcriptionErreur: String? /// Rappel de relance — fourni uniquement quand le statut vaut `echec`. var onRelancer: (() -> Void)? = nil /// Rappel de suppression avec confirmation intégrée. var onSupprimer: (() -> Void)? = nil @State private var joueur: AVAudioPlayer? @State private var enLecture = false @State private var confirmSupprimer = false var body: some View { VStack(alignment: .leading, spacing: 4) { // Titre + badge statut HStack(alignment: .top, spacing: 8) { Image(systemName: "waveform") .foregroundColor(C2VColor.texteFaible) .font(.system(size: 14)) Text(sujet) .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) .frame(maxWidth: .infinity, alignment: .leading) if let statut = transcriptionStatut, statut != "terminee" { statutBadge(statut) } } // Description / texte transcrit if !description.isEmpty { Text(description) .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) } // Motif d'erreur (quand echec) if let erreur = transcriptionErreur, !erreur.isEmpty { HStack(spacing: 4) { Image(systemName: "exclamationmark.circle") .font(.system(size: 11)) Text("Erreur : \(erreur)") .font(C2VFont.bodySmall) } .foregroundColor(C2VColor.brandError) } // Lecteur audio (fichier local uniquement) if let path = audioPath, FileManager.default.fileExists(atPath: path) { lecteurAudio(path: path) } // Actions if transcriptionStatut == "echec", let relancer = onRelancer { Button("Relancer la transcription") { relancer() } .buttonStyle(C2VTextButtonStyle()) .font(C2VFont.labelSmall) } if onSupprimer != nil { Button("Supprimer") { confirmSupprimer = true } .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError)) .font(C2VFont.labelSmall) } } .frame(maxWidth: .infinity, alignment: .leading) .confirmationDialog( "Supprimer cette note vocale ?", isPresented: $confirmSupprimer, titleVisibility: .visible ) { Button("Supprimer", role: .destructive) { onSupprimer?() } Button("Annuler", role: .cancel) {} } .onDisappear { joueur?.stop() joueur = nil enLecture = false } } // MARK: - Badge statut @ViewBuilder private func statutBadge(_ statut: String) -> some View { let (label, color): (String, Color) = switch statut { case "en_attente": ("En attente", C2VColor.brandWarn) case "echec": ("Échec", C2VColor.brandError) default: (statut, C2VColor.texteFaible) } Text(label) .font(C2VFont.labelSmall) .foregroundColor(color) .padding(.horizontal, 6) .padding(.vertical, 2) .overlay(Rectangle().stroke(color, lineWidth: 1)) } // MARK: - Lecteur audio private func lecteurAudio(path: String) -> some View { Button { toggleLecture(path: path) } label: { HStack(spacing: 6) { Image(systemName: enLecture ? "pause.circle" : "play.circle") .font(.system(size: 22)) Text(enLecture ? "Pause" : "Écouter") .font(C2VFont.labelSmall) } .foregroundColor(C2VColor.ink) } .buttonStyle(.plain) .padding(.top, 2) } private func toggleLecture(path: String) { if enLecture { joueur?.pause() enLecture = false } else { do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) let url = URL(fileURLWithPath: path) joueur = try AVAudioPlayer(contentsOf: url) joueur?.play() enLecture = true } catch { enLecture = false } } } }
GitRust