ScanCarteViewModel.kt
194 lignes · 7019 octets
package fr.ebii.card2vcf.ui import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.ebii.card2vcf.contact.ContactCard import fr.ebii.card2vcf.contact.ContactDraftMerge import fr.ebii.card2vcf.data.ContactRepository import fr.ebii.card2vcf.ocr.ModelesManquantsException import fr.ebii.card2vcf.ocr.OcrEngine import fr.ebii.card2vcf.ocr.OcrException import fr.ebii.card2vcf.scan.ScanEngine import fr.ebii.card2vcf.scan.ScanException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext sealed interface ScanUiState { data object Idle : ScanUiState data object Capturing : ScanUiState data object Scanning : ScanUiState data object OcrRunning : ScanUiState data object Structuring : ScanUiState data class DraftReady(val draft: ContactDraftUi) : ScanUiState /** * Aucun modèle OCR installé. La carte est bien photographiée et redressée * ([preview]) : seul le remplissage automatique des champs est impossible. * L'écran propose le téléchargement, ou la saisie manuelle sur cette image. */ data class ModelesManquants( val langues: List<String>, val preview: Bitmap?, ) : ScanUiState data class Error(val message: String) : ScanUiState } data class ContactDraftUi( val card: ContactCard, val rawOcrText: String, val preview: Bitmap? = null, ) class ScanCarteViewModel( private val scanEngine: ScanEngine, private val ocrEngine: OcrEngine, private val repository: ContactRepository, /** Dispatcher des étapes de calcul (redressement, OCR, structuration) ; injecté par les tests. */ private val calcul: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel() { /** Non-null when rescanning an existing contact (update path). */ var editingContactId: Long? = null val isEditing: Boolean get() = editingContactId != null private val _state = MutableStateFlow<ScanUiState>(ScanUiState.Idle) val state: StateFlow<ScanUiState> = _state.asStateFlow() private val _saveDoneId = MutableStateFlow<Long?>(null) val saveDoneId: StateFlow<Long?> = _saveDoneId.asStateFlow() fun onCaptureReady() { _state.value = ScanUiState.Capturing } /** Dernière capture redressée, conservée pour la saisie manuelle sans OCR. */ private var redressee: Bitmap? = null fun processCapture(bitmap: Bitmap) { viewModelScope.launch { try { _state.value = ScanUiState.Scanning val scanned = withContext(calcul) { scanEngine.scan(bitmap) } redressee = scanned.bitmap _state.value = ScanUiState.OcrRunning val ocr = withContext(calcul) { ocrEngine.recognize(scanned.bitmap) } _state.value = ScanUiState.Structuring var merged = withContext(calcul) { ContactDraftMerge.merge(null, ocr) } val editId = editingContactId if (editId != null) { val existing = withContext(Dispatchers.IO) { repository.getById(editId) } if (existing != null && !existing.notes.isNullOrBlank()) { merged = merged.copy(note = existing.notes) } } _state.value = ScanUiState.DraftReady( ContactDraftUi( card = merged, rawOcrText = ocr.rawText, preview = scanned.bitmap, ) ) } catch (e: ModelesManquantsException) { // Pas une panne : la capture est bonne, il manque juste les modèles. Log.i(TAG, "modèles OCR absents (${e.langues.joinToString()})") _state.value = ScanUiState.ModelesManquants(e.langues, redressee) } catch (e: ScanException) { Log.e(TAG, "scan", e) _state.value = ScanUiState.Error(e.message ?: "scan_erreur") } catch (e: OcrException) { Log.e(TAG, "ocr", e) _state.value = ScanUiState.Error(e.message ?: "ocr_erreur") } catch (t: Throwable) { Log.e(TAG, "pipeline", t) _state.value = ScanUiState.Error(t.message ?: "scan_erreur") } } } /** * Passe au brouillon sans OCR : tous les champs vides, mais la photo de carte * conservée — elle est enregistrée avec le contact et synchronisée comme d'habitude. */ fun saisirAlaMain() { val etat = _state.value as? ScanUiState.ModelesManquants ?: return _state.value = ScanUiState.DraftReady( ContactDraftUi( card = ContactCard(), rawOcrText = "", preview = etat.preview, ) ) } fun updateDraft(card: ContactCard) { val current = _state.value if (current is ScanUiState.DraftReady) { _state.value = current.copy(draft = current.draft.copy(card = card)) } } /** Enregistre le brouillon dans le carnet local (nouveau contact). Retourne l'id via [saveDoneId]. */ fun saveToCarnet() { if (editingContactId != null) { updateFromDraft() return } val draft = (_state.value as? ScanUiState.DraftReady)?.draft ?: return viewModelScope.launch { val id = withContext(Dispatchers.IO) { repository.insertFromCard( card = draft.card, cardBitmap = draft.preview, profileBitmap = null, ) } _saveDoneId.value = id } } /** Met à jour le contact existant (mode rescan) à partir du brouillon courant. */ fun updateFromDraft() { val editId = editingContactId ?: return val draft = (_state.value as? ScanUiState.DraftReady)?.draft ?: return viewModelScope.launch { withContext(Dispatchers.IO) { repository.updateFromCard( id = editId, card = draft.card, cardBitmap = draft.preview, profileBitmap = null, ) } _saveDoneId.value = editId } } fun consumeSaveDone() { _saveDoneId.value = null } fun reset() { redressee = null _state.value = ScanUiState.Idle } fun onCaptureDecodeFailed() { _state.value = ScanUiState.Error("Capture impossible — réessayez") } companion object { private const val TAG = "Card2vcfScan" } }
GitRust