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