ScanCarteViewModel.swift 119 lignes · 3800 octets
import Foundation
import UIKit

// Port of ui/ScanCarteViewModel.kt. The three Android pipeline stages
// (Scanning/OcrRunning/Structuring) collapse into one `.processing` state:
// `ScanPipeline.process` runs the whole chain in a single call.

enum ScanUiState {
    case idle
    case processing
    case draftReady(ContactDraftUi)
    case error(String)
}

struct ContactDraftUi {
    var card: ContactCard
    var rawOcrText: String
    var preview: UIImage?
}

@MainActor
final class ScanCarteViewModel: ObservableObject {
    @Published private(set) var state: ScanUiState = .idle
    @Published private(set) var saveDoneId: Int64?

    /// Non-nil when rescanning an existing contact (update path).
    var editingContactId: Int64?

    var isEditing: Bool { editingContactId != nil }

    private let pipeline: ScanPipeline
    private let repository: ContactRepository

    init(
        pipeline: ScanPipeline = ScanPipeline(),
        repository: ContactRepository = ContactRepository(
            dao: Card2vcfDatabase.shared.crmContactDao,
            images: ContactImageStore()
        )
    ) {
        self.pipeline = pipeline
        self.repository = repository
    }

    func processCapture(_ image: UIImage, source: ScanSource) {
        state = .processing
        Task {
            do {
                let output = try await pipeline.process(image, source: source)
                var card = output.draft
                // Rescan keeps the existing notes when the draft has none.
                if let editId = editingContactId,
                   let existing = try await repository.getById(editId),
                   let notes = existing.notes?.nilIfBlank {
                    card.note = notes
                }
                state = .draftReady(
                    ContactDraftUi(card: card, rawOcrText: output.ocrText, preview: output.preview)
                )
            } catch let e as ScanException {
                state = .error(e.message)
            } catch let e as OcrException {
                state = .error(e.message)
            } catch {
                state = .error(error.localizedDescription.nilIfBlank ?? "Capture impossible — réessayez")
            }
        }
    }

    func updateDraft(_ card: ContactCard) {
        if case .draftReady(var draft) = state {
            draft.card = card
            state = .draftReady(draft)
        }
    }

    /// Saves the draft into the local carnet (new contact), or updates the
    /// rescanned contact. Emits the id via `saveDoneId`.
    func saveToCarnet() {
        guard case .draftReady(let draft) = state else { return }
        if let editId = editingContactId {
            Task {
                do {
                    let path = draft.preview.flatMap { DraftImageWriter.writeJpeg($0, prefix: "card") }
                    try await repository.updateFromCard(
                        id: editId,
                        card: draft.card,
                        cardImagePath: path
                    )
                    saveDoneId = editId
                } catch {
                    state = .error(error.localizedDescription)
                }
            }
            return
        }
        Task {
            do {
                let path = draft.preview.flatMap { DraftImageWriter.writeJpeg($0, prefix: "card") }
                let id = try await repository.insertFromCard(draft.card, cardImagePath: path)
                saveDoneId = id
            } catch {
                state = .error(error.localizedDescription)
            }
        }
    }

    func consumeSaveDone() {
        saveDoneId = nil
    }

    func reset() {
        state = .idle
    }

    func onCaptureDecodeFailed() {
        state = .error("Capture impossible — réessayez")
    }
}