code client Ios

EBO <eric.bouhana@softalys.com> committé le 2026-09-13 20:29

0ff69c4468cd5f59dd915e9681e768231a1346dc

1 parent(s)

44 fichiers modifiés +1522 -150
M ios/Card2vcf/Contact/ContactCard.swift
+2 -0
@@ -11,6 +11,8 @@ struct ContactCard: Equatable {
11 11 var website: String? = nil
12 12 var address: String? = nil
13 13 var note: String? = nil
14 + /// Fields with low OCR confidence (keys: fullName, company, jobTitle, address).
15 + var champsDouteux: Set<String> = []
14 16
15 17 func displayName() -> String {
16 18 if let fullName = fullName?.nilIfBlank { return fullName }
M ios/Card2vcf/Contact/ContactDraftMerge.swift
+2 -1
@@ -18,7 +18,8 @@ enum ContactDraftMerge {
18 18 emails: mergeStrings(preferred: ocr.emails, extra: base.emails + heuristic.emails),
19 19 website: prefer(ocr.urls.first, prefer(base.website, heuristic.website)),
20 20 address: prefer(base.address, heuristic.address),
21 - note: prefer(base.note, heuristic.note)
21 + note: prefer(base.note, heuristic.note),
22 + champsDouteux: heuristic.champsDouteux
22 23 )
23 24 }
24 25
M ios/Card2vcf/Contact/ContactHeuristicParser.swift
+128 -2
@@ -4,6 +4,9 @@ import Foundation
4 4 /// Single structuring source in Card2vcf (no LLM).
5 5 enum ContactHeuristicParser {
6 6
7 + /// OCR confidence (0-100) under which a field is flagged « à vérifier ».
8 + private static let seuilConfiance: Float = 70
9 +
7 10 private static let jobKeywords = KotlinRegex(
8 11 #"(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|"# +
9 12 #"manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|"# +
@@ -31,6 +34,7 @@ enum ContactHeuristicParser {
31 34 private static let camelBoundary = KotlinRegex(#"(?<=[a-z])(?=[A-Z])"#)
32 35
33 36 static func parse(_ ocr: OcrResult) -> ContactCard {
37 + if !ocr.spatialLines.isEmpty { return parseSpatial(ocr) }
34 38 let lines: [String]
35 39 if ocr.lines.isEmpty {
36 40 lines = ocr.rawText.kotlinLines()
@@ -147,8 +151,11 @@ enum ContactHeuristicParser {
147 151 }
148 152 if let lastCaps, lastCaps > 0 {
149 153 let last = titleWord(parts[lastCaps])
150 - // Keep compound first names without forcing odd casing when already mixed.
151 - let firstKeep = parts[0..<lastCaps].joined(separator: " ")
154 + // Keep compound first names without forcing odd casing when already mixed;
155 + // but an all-caps token (« SONIA MARTIN ») goes back to Title Case.
156 + let firstKeep = parts[0..<lastCaps]
157 + .map { w in (w.count >= 2 && w == w.uppercased()) ? titleWord(w) : w }
158 + .joined(separator: " ")
152 159 return (firstKeep, last, "\(firstKeep) \(last)")
153 160 } else if parts.count >= 2 {
154 161 let last = titleWord(parts[parts.count - 1])
@@ -159,6 +166,125 @@ enum ContactHeuristicParser {
159 166 }
160 167 }
161 168
169 + /// Spatial path: exploits boxes/heights/blocks instead of flat text. Same
170 + /// classification regexes as the legacy path, enriched with layout signals
171 + /// (font size, grouping, labels).
172 + private static func parseSpatial(_ ocr: OcrResult) -> ContactCard {
173 + let blocks = BlockGrouper.group(ocr.spatialLines)
174 + // « Typical height » = low median: robust to giant logos and 2-line
175 + // cards where the true median would be pulled upward.
176 + let heights = ocr.spatialLines.map(\.box.height).sorted()
177 + let typicalHeight = heights[(heights.count - 1) / 2]
178 +
179 + func isData(_ t: String) -> Bool { emailOrUrlOrPhone.containsMatchIn(t) }
180 + func isLabel(_ t: String) -> Bool { skipLine.containsMatchIn(t) }
181 + // Kotlin maxByOrNull: first element with the maximum selector value.
182 + func firstMax<T>(_ candidates: [T], by score: (T) -> Int) -> T? {
183 + var best: T? = nil
184 + var bestScore = Int.min
185 + for c in candidates {
186 + let s = score(c)
187 + if s > bestScore {
188 + bestScore = s
189 + best = c
190 + }
191 + }
192 + return best
193 + }
194 +
195 + // Lone label (Tél. : / Mobile…) → the value sits on the block's next line.
196 + var phones = ocr.phones
197 + for block in blocks {
198 + for (label, value) in zip(block.lines, block.lines.dropFirst()) {
199 + if isLabel(label.text) && !isData(label.text) {
200 + for p in OcrPostProcessor.process(value.text).phones where !phones.contains(p) {
201 + phones.append(p)
202 + }
203 + }
204 + }
205 + }
206 +
207 + // Address: the most « address-like » block, multi-line join.
208 + func addressLineCount(_ b: OcrBlock) -> Int {
209 + b.lines.filter { addressKeywords.containsMatchIn($0.text) && !isData($0.text) }.count
210 + }
211 + let addressBlock = firstMax(blocks.filter { addressLineCount($0) > 0 }, by: addressLineCount)
212 + let address = addressBlock?.lines
213 + .filter { !isData($0.text) && !isLabel($0.text) }
214 + .map { whitespaceRun.replace($0.text, with: " ").trimmingCharacters(in: .whitespacesAndNewlines) }
215 + .joined(separator: ", ")
216 + .nilIfBlank
217 + let addressLines = addressBlock?.lines ?? []
218 +
219 + let rest = ocr.spatialLines.filter {
220 + !addressLines.contains($0) && !isData($0.text) && !isLabel($0.text)
221 + }
222 +
223 + let jobLine = rest.first { jobKeywords.containsMatchIn($0.text) }
224 +
225 + let emailDomain: String? = ocr.emails.first.flatMap { email in
226 + guard let atIdx = email.firstIndex(of: "@") else { return nil }
227 + let domain = String(email[email.index(after: atIdx)...].prefix(while: { $0 != "." }))
228 + return domain.count >= 3 ? domain : nil
229 + }
230 +
231 + // NB: « all caps » is NOT a company signal — person names are very often
232 + // capitalized on French business cards.
233 + func looksCompanySpatial(_ l: OcrLine) -> Bool {
234 + companyKeywords.containsMatchIn(l.text)
235 + || (emailDomain != nil
236 + && l.text.lowercased().replacingOccurrences(of: " ", with: "").contains(emailDomain!))
237 + }
238 +
239 + let nameLine = firstMax(
240 + rest.filter { $0 != jobLine && !looksCompanySpatial($0) && personLikeSpatial($0, typicalHeight: typicalHeight) }
241 + ) { personScore($0.text) + $0.box.height * 40 / typicalHeight }
242 + let (firstName, lastName, fullName) = splitPersonName(nameLine?.text)
243 +
244 + let companyLine = firstMax(
245 + rest.filter { $0 != jobLine && $0 != nameLine && looksCompanySpatial($0) }
246 + ) { $0.box.height * 1000 - $0.box.top } // large and near the top of the card
247 + let company = cleanCompany(companyLine?.text)
248 + ?? ocr.emails.first.flatMap { companyFromEmailDomain($0) }
249 +
250 + let noteParts = rest.filter {
251 + $0 != jobLine && $0 != nameLine && $0 != companyLine && $0.text.count > 2
252 + }
253 +
254 + var douteux: Set<String> = []
255 + if let nameLine, nameLine.confidence < seuilConfiance { douteux.insert("fullName") }
256 + if let jobLine, jobLine.confidence < seuilConfiance { douteux.insert("jobTitle") }
257 + if let companyLine, companyLine.confidence < seuilConfiance { douteux.insert("company") }
258 + if let addressBlock, addressBlock.avgConfidence() < seuilConfiance { douteux.insert("address") }
259 +
260 + return ContactCard(
261 + fullName: fullName,
262 + firstName: firstName,
263 + lastName: lastName,
264 + company: company,
265 + jobTitle: cleanJob(jobLine?.text),
266 + phones: phones,
267 + emails: ocr.emails,
268 + website: ocr.urls.first,
269 + address: address,
270 + note: noteParts.prefix(3).map(\.text).joined(separator: " · ").nilIfBlank,
271 + champsDouteux: douteux
272 + )
273 + }
274 +
275 + /// Like `looksLikePersonName`, with a capitals waiver for oversized characters.
276 + private static func personLikeSpatial(_ line: OcrLine, typicalHeight: Int) -> Bool {
277 + let t = nonNameChars.replace(line.text, with: " ")
278 + .trimmingCharacters(in: .whitespacesAndNewlines)
279 + guard (4...60).contains(t.count) else { return false }
280 + if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false }
281 + if addressKeywords.containsMatchIn(t) { return false }
282 + let parts = whitespaceRun.split(t).filter { $0.isNotBlank }
283 + guard (2...4).contains(parts.count) else { return false }
284 + let caps = parts.filter { $0.first?.isUppercase == true }.count
285 + return caps >= 2 || Double(line.box.height) >= 1.5 * Double(typicalHeight)
286 + }
287 +
162 288 private static func looksLikeCompany(_ line: String) -> Bool {
163 289 guard (2...50).contains(line.count) else { return false }
164 290 if emailOrUrlOrPhone.containsMatchIn(line) { return false }
M ios/Card2vcf/Data/ContactRepository.swift
+8 -0
@@ -133,6 +133,14 @@ final class ContactRepository {
133 133 updated.createdAt = existing.createdAt
134 134 updated.cardImagePath = existing.cardImagePath
135 135 updated.profileImagePath = existing.profileImagePath
136 + updated.serverId = existing.serverId
137 + updated.statut = existing.statut
138 + updated.etape = existing.etape
139 + updated.tags = existing.tags
140 + // When the company name changes, invalidate the link: the server re-resolves by name.
141 + let newCompany = card.company?.trimmingCharacters(in: .whitespacesAndNewlines)
142 + let oldCompany = existing.company?.trimmingCharacters(in: .whitespacesAndNewlines)
143 + updated.entrepriseServerId = newCompany == oldCompany ? existing.entrepriseServerId : nil
136 144 if let cardImagePath {
137 145 updated.cardImagePath = cardImagePath
138 146 }
M ios/Card2vcf/Data/Sqlite/SqliteDatabase.swift
+12 -5
@@ -19,7 +19,7 @@ struct DatabaseError: Error, CustomStringConvertible {
19 19 /// Owner of the sqlite3 connection. Mirrors the Room schema of the Android
20 20 /// `CrmDatabase` (version 3) with destructive migration semantics.
21 21 actor SqliteDatabase {
22 - static let schemaVersion: Int32 = 3
22 + static let schemaVersion: Int32 = 4
23 23 static let fileName = "card2vcf.sqlite"
24 24
25 25 private let inMemory: Bool
@@ -137,8 +137,8 @@ actor SqliteDatabase {
137 137 }
138 138 }
139 139
140 - /// Android uses `fallbackToDestructiveMigration()`: any stored version other
141 - /// than the current one drops everything and recreates the schema.
140 + /// Mirrors Android: additive migrations from v3 on (Room `MIGRATION_3_4`),
141 + /// destructive fallback (`fallbackToDestructiveMigration()`) for anything older.
142 142 private static func migrateIfNeeded(_ db: OpaquePointer) throws {
143 143 let stmt = try SqliteStatement(db: db, sql: "PRAGMA user_version")
144 144 var version: Int32 = 0
@@ -146,12 +146,18 @@ actor SqliteDatabase {
146 146 version = Int32(stmt.row.int64(0))
147 147 }
148 148 guard version != schemaVersion else { return }
149 + // v3→v4: `lastError` column on `sync_ops` (per-op push failure state).
150 + if version == 3 {
151 + try exec(db, "ALTER TABLE sync_ops ADD COLUMN lastError TEXT;")
152 + try exec(db, "PRAGMA user_version = \(schemaVersion);")
153 + return
154 + }
149 155 try exec(db, dropAllSql)
150 156 try exec(db, schemaSql)
151 157 try exec(db, "PRAGMA user_version = \(schemaVersion);")
152 158 }
153 159
154 - // MARK: - Schema (Room v3 mirror)
160 + // MARK: - Schema (Room v4 mirror)
155 161
156 162 private static let dropAllSql = """
157 163 DROP TRIGGER IF EXISTS trg_crm_contacts_ai;
@@ -311,7 +317,8 @@ actor SqliteDatabase {
311 317 localId INTEGER,
312 318 serverId TEXT,
313 319 createdAt INTEGER NOT NULL,
314 - attempts INTEGER NOT NULL
320 + attempts INTEGER NOT NULL,
321 + lastError TEXT
315 322 );
316 323
317 324 CREATE TABLE sync_meta (
M ios/Card2vcf/Data/Sqlite/SqliteSyncDaos.swift
+11 -2
@@ -3,7 +3,7 @@ import Foundation
3 3 struct SqliteSyncOpDao: SyncOpDao {
4 4 let db: SqliteDatabase
5 5
6 - private static let columns = "id, entityType, op, payloadJson, localId, serverId, createdAt, attempts"
6 + private static let columns = "id, entityType, op, payloadJson, localId, serverId, createdAt, attempts, lastError"
7 7
8 8 private static func map(_ row: SqliteRow) -> SyncOpEntity {
9 9 var e = SyncOpEntity(entityType: row.text(1), op: row.text(2))
@@ -13,13 +13,14 @@ struct SqliteSyncOpDao: SyncOpDao {
13 13 e.serverId = row.textOrNil(5)
14 14 e.createdAt = row.int64(6)
15 15 e.attempts = row.int(7)
16 + e.lastError = row.textOrNil(8)
16 17 return e
17 18 }
18 19
19 20 @discardableResult
20 21 func insert(_ entity: SyncOpEntity) async throws -> Int64 {
21 22 try await db.insert(
22 - "INSERT OR REPLACE INTO sync_ops (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
23 + "INSERT OR REPLACE INTO sync_ops (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
23 24 [
24 25 .rowId(entity.id),
25 26 .text(entity.entityType),
@@ -29,10 +30,18 @@ struct SqliteSyncOpDao: SyncOpDao {
29 30 .optText(entity.serverId),
30 31 .int(entity.createdAt),
31 32 .int(Int64(entity.attempts)),
33 + .optText(entity.lastError),
32 34 ]
33 35 )
34 36 }
35 37
38 + func markFailure(id: Int64, error: String?) async throws {
39 + try await db.write(
40 + "UPDATE sync_ops SET attempts = attempts + 1, lastError = ? WHERE id = ?",
41 + [.optText(error), .int(id)]
42 + )
43 + }
44 +
36 45 func listAll() async throws -> [SyncOpEntity] {
37 46 try await db.query(
38 47 "SELECT \(Self.columns) FROM sync_ops ORDER BY createdAt ASC",
M ios/Card2vcf/Data/SyncOpDao.swift
+3 -0
@@ -12,6 +12,9 @@ protocol SyncOpDao {
12 12 /// SELECT * FROM sync_ops WHERE id = :id
13 13 func getById(_ id: Int64) async throws -> SyncOpEntity?
14 14
15 + /// UPDATE sync_ops SET attempts = attempts + 1, lastError = :error WHERE id = :id
16 + func markFailure(id: Int64, error: String?) async throws
17 +
15 18 /// DELETE FROM sync_ops WHERE id = :id
16 19 func deleteById(_ id: Int64) async throws
17 20 }
M ios/Card2vcf/Data/SyncOpEntity.swift
+1 -0
@@ -10,4 +10,5 @@ struct SyncOpEntity: Equatable {
10 10 var serverId: String? = nil
11 11 var createdAt: Int64 = 0
12 12 var attempts: Int = 0
13 + var lastError: String? = nil
13 14 }
A ios/Card2vcf/OCR/BlockGrouper.swift
+50 -0
@@ -0,0 +1,50 @@
1 +import Foundation
2 +
3 +/// Groups OCR lines into spatial blocks: a line joins the closest block that
4 +/// overlaps it horizontally (same column) when the vertical gap stays under
5 +/// `gapFactor` × median line height. The median keeps the threshold robust to
6 +/// giant lines (logos). O(n × blocks), n ≈ 10-40 lines.
7 +enum BlockGrouper {
8 + private static let gapFactor = 1.8
9 +
10 + static func group(_ lines: [OcrLine]) -> [OcrBlock] {
11 + if lines.isEmpty { return [] }
12 + let sorted = lines.sorted {
13 + ($0.box.top, $0.box.left) < ($1.box.top, $1.box.left)
14 + }
15 + let maxGap = medianHeight(sorted) * gapFactor
16 +
17 + var blocks: [[OcrLine]] = []
18 + for line in sorted {
19 + // Kotlin maxByOrNull: first block with the lowest bottom edge.
20 + var candidateIndex: Int? = nil
21 + var candidateBottom = Int.min
22 + for (index, block) in blocks.enumerated() {
23 + let blockBox = block.map(\.box).reduce(block[0].box) { $0.union($1) }
24 + guard blockBox.overlapsHorizontally(line.box),
25 + Double(line.box.top - blockBox.bottom) <= maxGap else { continue }
26 + let bottom = block.map(\.box.bottom).max() ?? Int.min
27 + if bottom > candidateBottom {
28 + candidateBottom = bottom
29 + candidateIndex = index
30 + }
31 + }
32 + if let candidateIndex {
33 + blocks[candidateIndex].append(line)
34 + } else {
35 + blocks.append([line])
36 + }
37 + }
38 +
39 + return blocks
40 + .map { OcrBlock(lines: $0) }
41 + .sorted { ($0.box().top, $0.box().left) < ($1.box().top, $1.box().left) }
42 + }
43 +
44 + private static func medianHeight(_ lines: [OcrLine]) -> Double {
45 + let heights = lines.map(\.box.height).sorted()
46 + let mid = heights.count / 2
47 + if heights.count % 2 == 1 { return Double(heights[mid]) }
48 + return Double(heights[mid - 1] + heights[mid]) / 2.0
49 + }
50 +}
A ios/Card2vcf/OCR/OcrLine.swift
+54 -0
@@ -0,0 +1,54 @@
1 +import Foundation
2 +
3 +/// Bounding box in pixels of the OCRed image (y axis pointing down).
4 +/// Plain struct (no CoreGraphics): the model and the parser stay testable
5 +/// without UIKit. Only RELATIVE positions matter (heights, gaps, alignments) —
6 +/// the absolute frame depends on the candidate (preprocessing, orientation)
7 +/// but is consistent within a single result.
8 +struct OcrBox: Equatable {
9 + var left: Int
10 + var top: Int
11 + var right: Int
12 + var bottom: Int
13 +
14 + var height: Int { bottom - top }
15 + var width: Int { right - left }
16 +
17 + func overlapsHorizontally(_ other: OcrBox) -> Bool {
18 + left < other.right && other.left < right
19 + }
20 +
21 + func union(_ other: OcrBox) -> OcrBox {
22 + OcrBox(
23 + left: min(left, other.left),
24 + top: min(top, other.top),
25 + right: max(right, other.right),
26 + bottom: max(bottom, other.bottom)
27 + )
28 + }
29 +}
30 +
31 +/// Recognized text line with its box and confidence (0-100, Tesseract scale).
32 +struct OcrLine: Equatable {
33 + var text: String
34 + var box: OcrBox
35 + var confidence: Float
36 +}
37 +
38 +/// Spatially coherent group of lines (address block, contact block…).
39 +struct OcrBlock: Equatable {
40 + var lines: [OcrLine]
41 +
42 + func text() -> String {
43 + lines.map(\.text).joined(separator: "\n")
44 + }
45 +
46 + func box() -> OcrBox {
47 + lines.map(\.box).reduce(lines[0].box) { $0.union($1) }
48 + }
49 +
50 + func avgConfidence() -> Float {
51 + if lines.isEmpty { return 0 }
52 + return lines.map(\.confidence).reduce(0, +) / Float(lines.count)
53 + }
54 +}
M ios/Card2vcf/OCR/OcrPostProcessor.swift
+14 -8
@@ -18,12 +18,12 @@ enum OcrPostProcessor {
18 18 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
19 19 .filter { !$0.isEmpty }
20 20 let joined = lines.joined(separator: "\n")
21 - // Fix O/0 only inside numeric sequences (frequent OCR errors).
22 - let phoneSource = oBeforeDigit.replace(
23 - oBetweenDigits.replace(joined, with: "0"),
24 - with: "0"
25 - )
26 - let phones = phoneRegex.findAll(phoneSource)
21 + // Line-by-line extraction: avoids absorbing the postal code or street
22 + // number of a neighbouring line (\s also matches \n in phoneRegex).
23 + let phones = lines
24 + // Fix O/0 only inside numeric sequences (frequent OCR errors).
25 + .map { oBeforeDigit.replace(oBetweenDigits.replace($0, with: "0"), with: "0") }
26 + .flatMap { phoneRegex.findAll($0) }
27 27 .map { normalizePhone($0) }
28 28 .filter { isPlausiblePhone($0) }
29 29 .distinctPreservingOrder()
@@ -50,7 +50,8 @@ enum OcrPostProcessor {
50 50 phones: (base.phones + extra.phones).distinctPreservingOrder(),
51 51 emails: (base.emails + extra.emails).distinctPreservingOrder(),
52 52 urls: (base.urls + extra.urls).distinctPreservingOrder(),
53 - confidence: base.confidence
53 + confidence: base.confidence,
54 + spatialLines: base.spatialLines
54 55 )
55 56 }
56 57
@@ -75,7 +76,12 @@ enum OcrPostProcessor {
75 76
76 77 private static func isPlausiblePhone(_ phone: String) -> Bool {
77 78 let digits = phone.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
78 - return (10...15).contains(digits.count)
79 + // With an international prefix: 11-15 digits. Without: strict FR national
80 + // format (10 digits, leading 0) to reject address+phone merges.
81 + if phone.hasPrefix("+") {
82 + return (11...15).contains(digits.count)
83 + }
84 + return digits.count == 10 && digits.first == "0"
79 85 }
80 86
81 87 private static func normalizeUrl(_ raw: String) -> String {
M ios/Card2vcf/OCR/OcrResult.swift
+2 -0
@@ -7,4 +7,6 @@ struct OcrResult: Equatable {
7 7 var emails: [String] = []
8 8 var urls: [String] = []
9 9 var confidence: Float? = nil
10 + /// Lines with bounding boxes; empty = flat-text pipeline.
11 + var spatialLines: [OcrLine] = []
10 12 }
M ios/Card2vcf/OCR/VisionOcrEngine.swift
+31 -14
@@ -57,21 +57,25 @@ final class VisionOcrEngine: OcrEngine {
57 57 return best
58 58 }
59 59
60 - /// Mirrors `TesseractOcrEngine.recognizeOnce`: raw text -> OcrPostProcessor.
60 + /// Mirrors `TesseractOcrEngine.recognizeOnce`: raw text -> OcrPostProcessor,
61 + /// plus the spatial lines (boxes + confidence) for the layout-aware parser.
61 62 private func recognizeOnce(
62 63 cgImage: CGImage,
63 64 orientation: CGImagePropertyOrientation
64 65 ) async throws -> OcrResult {
65 - let raw = try await performVision(cgImage: cgImage, orientation: orientation)
66 + let spatial = try await performVision(cgImage: cgImage, orientation: orientation)
67 + let raw = spatial.map(\.text).joined(separator: "\n")
66 68 let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
67 69 if trimmed.isEmpty { throw OcrException("OCR vide") }
68 - return OcrPostProcessor.process(trimmed)
70 + var result = OcrPostProcessor.process(trimmed)
71 + result.spatialLines = spatial
72 + return result
69 73 }
70 74
71 75 private func performVision(
72 76 cgImage: CGImage,
73 77 orientation: CGImagePropertyOrientation
74 - ) async throws -> String {
78 + ) async throws -> [OcrLine] {
75 79 let languages = recognitionLanguages
76 80 return try await withCheckedThrowingContinuation { continuation in
77 81 DispatchQueue.global(qos: .userInitiated).async {
@@ -84,7 +88,7 @@ final class VisionOcrEngine: OcrEngine {
84 88 let handler = VNImageRequestHandler(cgImage: cgImage, orientation: orientation)
85 89 do {
86 90 try handler.perform([request])
87 - continuation.resume(returning: Self.joinTopToBottom(request.results ?? []))
91 + continuation.resume(returning: Self.spatialLines(request.results ?? []))
88 92 } catch {
89 93 continuation.resume(throwing: OcrException("Vision OCR a échoué", underlying: error))
90 94 }
@@ -92,21 +96,34 @@ final class VisionOcrEngine: OcrEngine {
92 96 }
93 97 }
94 98
95 - /// Full text = lines joined top-to-bottom by vertical position
96 - /// (Vision coordinates are normalized with a bottom-left origin).
97 - private static func joinTopToBottom(_ observations: [VNRecognizedTextObservation]) -> String {
99 + /// Observations → `OcrLine`s sorted top-to-bottom then left-to-right.
100 + ///
101 + /// Vision boxes are normalized (0-1) with a bottom-left origin; `OcrLine`
102 + /// expects a y-down frame where only RELATIVE positions matter, so the
103 + /// boxes are mapped into a virtual 1000×1000 space with a flipped y axis.
104 + /// Vision confidence (0-1) is scaled to the Tesseract range (0-100).
105 + private static func spatialLines(_ observations: [VNRecognizedTextObservation]) -> [OcrLine] {
98 106 observations
99 - .compactMap { observation -> (y: CGFloat, x: CGFloat, text: String)? in
107 + .compactMap { observation -> OcrLine? in
100 108 guard let candidate = observation.topCandidates(1).first else { return nil }
109 + let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
110 + guard !text.isEmpty else { return nil }
101 111 let box = observation.boundingBox
102 - return (box.midY, box.minX, candidate.string)
112 + return OcrLine(
113 + text: text,
114 + box: OcrBox(
115 + left: Int(box.minX * 1000),
116 + top: Int((1 - box.maxY) * 1000),
117 + right: Int(box.maxX * 1000),
118 + bottom: Int((1 - box.minY) * 1000)
119 + ),
120 + confidence: candidate.confidence * 100
121 + )
103 122 }
104 123 .sorted { a, b in
105 - if abs(a.y - b.y) > 0.01 { return a.y > b.y }
106 - return a.x < b.x
124 + if abs(a.box.top - b.box.top) > 10 { return a.box.top < b.box.top }
125 + return a.box.left < b.box.left
107 126 }
108 - .map(\.text)
109 - .joined(separator: "\n")
110 127 }
111 128
112 129 /// Keeps only languages Vision supports on this OS (exact code, then base-language match).
M ios/Card2vcf/Sync/AgendaSyncCoordinator.swift
+10 -3
@@ -135,7 +135,7 @@ final class AgendaSyncCoordinator {
135 135 entity.cibleId = cibleId
136 136 entity.debutMs = snap.debutMs
137 137 entity.finMs = snap.finMs
138 - entity.motif = snap.descriptionBody
138 + entity.motif = Self.motifReservation(snap)
139 139 entity.calendarEventId = eventId
140 140 entity.updatedAt = Self.currentMillis()
141 141 entity.dirtyLocal = true
@@ -154,7 +154,7 @@ final class AgendaSyncCoordinator {
154 154 var updated = local
155 155 updated.debutMs = snap.debutMs
156 156 updated.finMs = snap.finMs
157 - updated.motif = snap.descriptionBody
157 + updated.motif = Self.motifReservation(snap)
158 158 updated.updatedAt = Self.currentMillis()
159 159 updated.dirtyLocal = true
160 160 try await db.reservationDao.upsert(updated)
@@ -186,11 +186,18 @@ final class AgendaSyncCoordinator {
186 186 }
187 187
188 188 private func reservationDiffers(_ entity: ReservationEntity, from snap: CalendarEventSnapshot) -> Bool {
189 - entity.motif != snap.descriptionBody
189 + entity.motif != Self.motifReservation(snap)
190 190 || entity.debutMs != snap.debutMs
191 191 || entity.finMs != snap.finMs
192 192 }
193 193
194 + /// Motif of a pushed reservation: the event TITLE (symmetric with the downward
195 + /// direction, which displays the motif as title — see `roomToAgenda`), the
196 + /// description as fallback. The server refuses an empty motif.
197 + private static func motifReservation(_ snap: CalendarEventSnapshot) -> String {
198 + snap.title.isBlank ? snap.descriptionBody : snap.title
199 + }
200 +
194 201 /// Replaces any pending op for this local entity before inserting the new one (avoids piling up).
195 202 private func enqueueOp(
196 203 entityType: String,
M ios/Card2vcf/Sync/CalendarBridge.swift
+11 -0
@@ -49,6 +49,9 @@ protocol CalendarBridge {
49 49 @discardableResult
50 50 func ensureLocalCalendar(displayName: String) throws -> Int64
51 51
52 + /// Deletes the local calendar and (cascading, like Android's provider) all its events.
53 + func deleteCalendar(calendarId: Int64) throws
54 +
52 55 func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot]
53 56
54 57 @discardableResult
@@ -120,6 +123,14 @@ final class EventKitCalendarBridge: CalendarBridge {
120 123 return ids.id(for: calendar.calendarIdentifier)
121 124 }
122 125
126 + /// Removes the local calendar `calendarId` from the device; EventKit deletes its
127 + /// events in cascade. An unknown id is a no-op (Android `delete` returning 0).
128 + func deleteCalendar(calendarId: Int64) throws {
129 + guard let calendar = calendar(for: calendarId) else { return }
130 + try store.removeCalendar(calendar, commit: true)
131 + ids.remove(id: calendarId)
132 + }
133 +
123 134 func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] {
124 135 guard let calendar = calendar(for: calendarId) else { return [] }
125 136 let start = Date(timeIntervalSinceNow: -365 * 24 * 3600)
M ios/Card2vcf/Sync/ContactSyncMapper.swift
+5 -1
@@ -13,10 +13,14 @@ struct ContactSyncMapper: ContactSyncPayloadMapping {
13 13
14 14 private static func request(from entity: CrmContactEntity) -> CreateContactRequest {
15 15 let (prenom, nom) = resolveNames(entity)
16 + let entrepriseId = nonBlank(entity.entrepriseServerId)
17 + let company = entity.company?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
16 18 return CreateContactRequest(
17 19 prenom: prenom,
18 20 nom: nom,
19 - entrepriseId: nonBlank(entity.entrepriseServerId),
21 + entrepriseId: entrepriseId,
22 + // Scanned company name sent only without a server link: the server resolves or creates.
23 + entrepriseNom: (entrepriseId == nil && !company.isEmpty) ? company : nil,
20 24 fonction: entity.jobTitle ?? "",
21 25 emails: entity.emails.compactMap(nonBlank).map { ContactValeurDto(valeur: $0.trimmingCharacters(in: .whitespacesAndNewlines)) },
22 26 telephones: entity.phones.compactMap(nonBlank).map { ContactValeurDto(valeur: $0.trimmingCharacters(in: .whitespacesAndNewlines)) },
M ios/Card2vcf/Sync/SyncEngine.swift
+155 -69
@@ -6,11 +6,24 @@ struct SyncStatusResult {
6 6 var error: String? = nil
7 7 }
8 8
9 +/// Op refused by the server and left queued. `message` is the verbatim server message
10 +/// (« Réservation refusée : période en conflit avec Entretien »), `nil` when the op never
11 +/// even reached the network (unresolved local reference); `code` is -1 for a network failure.
12 +struct PushFailure: Equatable {
13 + var entityType: String
14 + var op: String
15 + var code: Int? = nil
16 + var message: String? = nil
17 +}
18 +
9 19 struct SyncResult {
10 20 var success: Bool
11 21 var serverTime: String? = nil
12 22 var error: String? = nil
13 23 var conflicts: [AgendaConflict] = []
24 + var pushed: Int = 0
25 + var received: Int = 0
26 + var pushFailures: [PushFailure] = []
14 27 }
15 28
16 29 /// Pushes the `SyncOpEntity` queue then pulls `/api/sync/pull`; LWW / append / tombstones.
@@ -19,18 +32,30 @@ struct SyncResult {
19 32 /// the push (including the reservation 409 special case).
20 33 final class SyncEngine {
21 34 private static let watermarkKey = "watermark"
35 + private static let watermarkOriginKey = "watermark_origin"
22 36 private static let defaultWatermark = "1970-01-01T00:00:00Z"
23 37 private static let entityContactMedia = "contact_media"
24 38
25 39 private let api: AilianceApi
26 40 private let db: CrmDatabase
27 41 private let imageStore: ContactImageSaving?
42 + /// « serveur|utilisateur » identity the watermark belongs to. When it differs from the
43 + /// stored one (server or account change), the pull restarts from the epoch: otherwise a
44 + /// watermark inherited from another server hides any older data (e.g. backdated demo
45 + /// data → « 0 reçu » forever). `nil` = no check.
46 + private let serverIdentity: String?
28 47 private let agenda: AgendaSyncCoordinator
29 48
30 - init(api: AilianceApi, db: CrmDatabase, imageStore: ContactImageSaving? = nil) {
49 + init(
50 + api: AilianceApi,
51 + db: CrmDatabase,
52 + imageStore: ContactImageSaving? = nil,
53 + serverIdentity: String? = nil
54 + ) {
31 55 self.api = api
32 56 self.db = db
33 57 self.imageStore = imageStore
58 + self.serverIdentity = serverIdentity
34 59 self.agenda = AgendaSyncCoordinator(db: db)
35 60 }
36 61
@@ -53,19 +78,32 @@ final class SyncEngine {
53 78 try await agenda.agendaToRoom(bindings: bindings, bridge: bridge!)
54 79 }
55 80
56 - let conflicts = try await pushOps()
81 + let push = try await pushOps()
57 82 let ressourcesQuery = agendaEnabled ? agenda.ressourcesQuery(bindings) : nil
58 83
59 84 switch await api.syncPull(sinceIso: try await watermark(), ressourcesQuery: ressourcesQuery) {
60 85 case .ok(let pull):
61 - try await applyPull(pull)
86 + let received = try await applyPull(pull)
62 87 if agendaEnabled {
63 88 try await agenda.roomToAgenda(bindings: bindings, bridge: bridge!)
64 89 }
65 90 try await setWatermark(pull.serverTime)
66 - return SyncResult(success: true, serverTime: pull.serverTime, conflicts: conflicts)
91 + return SyncResult(
92 + success: true,
93 + serverTime: pull.serverTime,
94 + conflicts: push.conflicts,
95 + pushed: push.succeeded,
96 + received: received,
97 + pushFailures: push.failures
98 + )
67 99 case .err(_, let message):
68 - return SyncResult(success: false, error: message, conflicts: conflicts)
100 + return SyncResult(
101 + success: false,
102 + error: message,
103 + conflicts: push.conflicts,
104 + pushed: push.succeeded,
105 + pushFailures: push.failures
106 + )
69 107 }
70 108 }
71 109
@@ -106,56 +144,100 @@ final class SyncEngine {
106 144 // ---- watermark ----
107 145
108 146 private func watermark() async throws -> String {
109 - try await db.syncMetaDao.get(Self.watermarkKey)?.value ?? Self.defaultWatermark
147 + guard let stored = try await db.syncMetaDao.get(Self.watermarkKey)?.value else {
148 + return Self.defaultWatermark
149 + }
150 + if let serverIdentity {
151 + let origin = try await db.syncMetaDao.get(Self.watermarkOriginKey)?.value
152 + if origin != serverIdentity { return Self.defaultWatermark }
153 + }
154 + return stored
110 155 }
111 156
112 157 private func setWatermark(_ serverTime: String) async throws {
113 158 try await db.syncMetaDao.upsert(SyncMetaEntity(key: Self.watermarkKey, value: serverTime))
159 + if let serverIdentity {
160 + try await db.syncMetaDao.upsert(SyncMetaEntity(key: Self.watermarkOriginKey, value: serverIdentity))
161 + }
114 162 }
115 163
116 164 // ---- push ----
117 165
118 - /// Outcome of a pushed op: `conflict` is non-nil only for a reservation 409 (`ok` stays `false`).
166 + /// Outcome of a pushed op. `conflict` is non-nil only for a reservation 409 (`ok` stays
167 + /// `false`): that case is arbitrated by the dedicated dialog, not a generic failure.
168 + /// `code`/`message` carry the server response for every other refusal, so it can be
169 + /// surfaced to the user.
119 170 private struct PushOutcome {
120 171 var ok: Bool
121 172 var conflict: AgendaConflict? = nil
173 + var code: Int? = nil
174 + var message: String? = nil
175 + }
176 +
177 + /// Summary of a push cycle: attempted / dequeued ops, 409 conflicts and generic failures.
178 + private struct PushReport {
179 + var attempted: Int
180 + var succeeded: Int
181 + var conflicts: [AgendaConflict]
182 + var failures: [PushFailure]
122 183 }
123 184
124 - private func pushOps() async throws -> [AgendaConflict] {
185 + /// Keeps the server `code`/`message` on refusal, so `pushOps` can surface them to the UI.
186 + private static func outcome<T>(_ result: AilianceApiClient.ApiResult<T>) -> PushOutcome {
187 + switch result {
188 + case .ok:
189 + return PushOutcome(ok: true)
190 + case .err(let code, let message):
191 + return PushOutcome(ok: false, code: code, message: message)
192 + }
193 + }
194 +
195 + private func pushOps() async throws -> PushReport {
125 196 var conflicts: [AgendaConflict] = []
197 + var failures: [PushFailure] = []
198 + var attempted = 0
199 + var succeeded = 0
126 200 for op in try await db.syncOpDao.listAll() {
201 + attempted += 1
127 202 let outcome: PushOutcome
128 203 switch op.entityType {
129 204 case "contact":
130 - outcome = PushOutcome(ok: try await pushContactOp(op))
205 + outcome = try await pushContactOp(op)
131 206 case Self.entityContactMedia:
132 - outcome = PushOutcome(ok: try await pushContactMediaOp(op))
207 + outcome = try await pushContactMediaOp(op)
133 208 case "entreprise":
134 - outcome = PushOutcome(ok: try await pushEntrepriseOp(op))
209 + outcome = try await pushEntrepriseOp(op)
135 210 case "projet":
136 - outcome = PushOutcome(ok: try await pushProjetOp(op))
211 + outcome = try await pushProjetOp(op)
137 212 case "tache":
138 - outcome = PushOutcome(ok: try await pushTacheOp(op))
213 + outcome = try await pushTacheOp(op)
139 214 case "interaction":
140 - outcome = PushOutcome(ok: try await pushInteractionOp(op))
215 + outcome = try await pushInteractionOp(op)
141 216 case AgendaSyncCoordinator.kindRdv:
142 - outcome = PushOutcome(ok: try await pushRdvOp(op))
217 + outcome = try await pushRdvOp(op)
143 218 case AgendaSyncCoordinator.entityReservation:
144 219 outcome = try await pushReservationOp(op)
145 220 default:
146 221 outcome = PushOutcome(ok: true)
147 222 }
148 223 if outcome.ok {
224 + succeeded += 1
149 225 try await db.syncOpDao.deleteById(op.id)
226 + } else if outcome.conflict == nil {
227 + failures.append(
228 + PushFailure(entityType: op.entityType, op: op.op, code: outcome.code, message: outcome.message)
229 + )
230 + let error = outcome.message ?? outcome.code.map { "HTTP \($0)" } ?? "réseau"
231 + try await db.syncOpDao.markFailure(id: op.id, error: error)
150 232 }
151 233 if let conflict = outcome.conflict {
152 234 conflicts.append(conflict)
153 235 }
154 236 }
155 - return conflicts
237 + return PushReport(attempted: attempted, succeeded: succeeded, conflicts: conflicts, failures: failures)
156 238 }
157 239
158 - private func pushContactOp(_ op: SyncOpEntity) async throws -> Bool {
240 + private func pushContactOp(_ op: SyncOpEntity) async throws -> PushOutcome {
159 241 switch op.op {
160 242 case "create":
161 243 let result = await api.createContact(jsonBody: op.payloadJson)
@@ -170,10 +252,11 @@ final class SyncEngine {
170 252 }
171 253 }
172 254 }
173 - return result.isOk
255 + return Self.outcome(result)
174 256 case "update":
175 - guard let serverId = op.serverId else { return false }
176 - guard await api.updateContact(id: serverId, jsonBody: op.payloadJson).isOk else { return false }
257 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
258 + let result = await api.updateContact(id: serverId, jsonBody: op.payloadJson)
259 + guard result.isOk else { return Self.outcome(result) }
177 260 let localId: Int64?
178 261 if let opLocalId = op.localId {
179 262 localId = opLocalId
@@ -183,25 +266,25 @@ final class SyncEngine {
183 266 if let localId, try await !pushContactImages(localId: localId, serverId: serverId) {
184 267 try await enqueueContactMediaRetry(localId: localId, serverId: serverId)
185 268 }
186 - return true
269 + return PushOutcome(ok: true)
187 270 case "delete":
188 - guard let serverId = op.serverId else { return false }
189 - return await api.deleteContact(id: serverId).isOk
271 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
272 + return Self.outcome(await api.deleteContact(id: serverId))
190 273 default:
191 - return true
274 + return PushOutcome(ok: true)
192 275 }
193 276 }
194 277
195 - private func pushContactMediaOp(_ op: SyncOpEntity) async throws -> Bool {
196 - guard let serverId = op.serverId else { return true }
278 + private func pushContactMediaOp(_ op: SyncOpEntity) async throws -> PushOutcome {
279 + guard let serverId = op.serverId else { return PushOutcome(ok: true) }
197 280 let localId: Int64?
198 281 if let opLocalId = op.localId {
199 282 localId = opLocalId
200 283 } else {
201 284 localId = try await db.crmContactDao.getByServerId(serverId)?.id
202 285 }
203 - guard let localId else { return true }
204 - return try await pushContactImages(localId: localId, serverId: serverId)
286 + guard let localId else { return PushOutcome(ok: true) }
287 + return PushOutcome(ok: try await pushContactImages(localId: localId, serverId: serverId))
205 288 }
206 289
207 290 /// Uploads the local card/photo files to the server. `true` when nothing to send or all OK.
@@ -233,7 +316,7 @@ final class SyncEngine {
233 316 try await db.syncOpDao.insert(entity)
234 317 }
235 318
236 - private func pushEntrepriseOp(_ op: SyncOpEntity) async throws -> Bool {
319 + private func pushEntrepriseOp(_ op: SyncOpEntity) async throws -> PushOutcome {
237 320 switch op.op {
238 321 case "create":
239 322 let result = await api.createEntreprise(jsonBody: op.payloadJson)
@@ -245,19 +328,19 @@ final class SyncEngine {
245 328 }
246 329 }
247 330 }
248 - return result.isOk
331 + return Self.outcome(result)
249 332 case "update":
250 - guard let serverId = op.serverId else { return false }
251 - return await api.updateEntreprise(id: serverId, jsonBody: op.payloadJson).isOk
333 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
334 + return Self.outcome(await api.updateEntreprise(id: serverId, jsonBody: op.payloadJson))
252 335 case "delete":
253 - guard let serverId = op.serverId else { return false }
254 - return await api.deleteEntreprise(id: serverId).isOk
336 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
337 + return Self.outcome(await api.deleteEntreprise(id: serverId))
255 338 default:
256 - return true
339 + return PushOutcome(ok: true)
257 340 }
258 341 }
259 342
260 - private func pushProjetOp(_ op: SyncOpEntity) async throws -> Bool {
343 + private func pushProjetOp(_ op: SyncOpEntity) async throws -> PushOutcome {
261 344 switch op.op {
262 345 case "create":
263 346 let result = await api.createProjet(jsonBody: op.payloadJson)
@@ -269,15 +352,15 @@ final class SyncEngine {
269 352 }
270 353 }
271 354 }
272 - return result.isOk
355 + return Self.outcome(result)
273 356 case "update":
274 - guard let serverId = op.serverId else { return false }
275 - return await api.updateProjet(id: serverId, jsonBody: op.payloadJson).isOk
357 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
358 + return Self.outcome(await api.updateProjet(id: serverId, jsonBody: op.payloadJson))
276 359 case "delete":
277 - guard let serverId = op.serverId else { return false }
278 - return await api.deleteProjet(id: serverId).isOk
360 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
361 + return Self.outcome(await api.deleteProjet(id: serverId))
279 362 default:
280 - return true
363 + return PushOutcome(ok: true)
281 364 }
282 365 }
283 366
@@ -292,8 +375,8 @@ final class SyncEngine {
292 375 return nil
293 376 }
294 377
295 - private func pushTacheOp(_ op: SyncOpEntity) async throws -> Bool {
296 - guard let projetId = try await resolveTacheProjetId(op) else { return false }
378 + private func pushTacheOp(_ op: SyncOpEntity) async throws -> PushOutcome {
379 + guard let projetId = try await resolveTacheProjetId(op) else { return PushOutcome(ok: false) }
297 380 switch op.op {
298 381 case "create":
299 382 let result = await api.createTache(projetId: projetId, jsonBody: op.payloadJson)
@@ -305,29 +388,29 @@ final class SyncEngine {
305 388 }
306 389 }
307 390 }
308 - return result.isOk
391 + return Self.outcome(result)
309 392 case "update":
310 - guard let serverId = op.serverId else { return false }
311 - return await api.updateTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson).isOk
393 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
394 + return Self.outcome(await api.updateTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson))
312 395 case "delete":
313 - guard let serverId = op.serverId else { return false }
314 - return await api.deleteTache(projetId: projetId, tacheId: serverId).isOk
396 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
397 + return Self.outcome(await api.deleteTache(projetId: projetId, tacheId: serverId))
315 398 case "move":
316 - guard let serverId = op.serverId else { return false }
317 - return await api.moveTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson).isOk
399 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
400 + return Self.outcome(await api.moveTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson))
318 401 default:
319 - return true
402 + return PushOutcome(ok: true)
320 403 }
321 404 }
322 405
323 406 /// `op.serverId` carries the target contact's `serverId` (interactions have no update/delete).
324 - private func pushInteractionOp(_ op: SyncOpEntity) async throws -> Bool {
325 - if op.op != "create" { return true }
326 - guard let contactServerId = op.serverId else { return false }
327 - return await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson).isOk
407 + private func pushInteractionOp(_ op: SyncOpEntity) async throws -> PushOutcome {
408 + if op.op != "create" { return PushOutcome(ok: true) }
409 + guard let contactServerId = op.serverId else { return PushOutcome(ok: false) }
410 + return Self.outcome(await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson))
328 411 }
329 412
330 - private func pushRdvOp(_ op: SyncOpEntity) async throws -> Bool {
413 + private func pushRdvOp(_ op: SyncOpEntity) async throws -> PushOutcome {
331 414 switch op.op {
332 415 case "create":
333 416 let result = await api.createRdv(jsonBody: op.payloadJson)
@@ -340,20 +423,20 @@ final class SyncEngine {
340 423 }
341 424 }
342 425 }
343 - return result.isOk
426 + return Self.outcome(result)
344 427 case "update":
345 - guard let serverId = op.serverId else { return false }
346 - let ok = await api.updateRdv(id: serverId, jsonBody: op.payloadJson).isOk
347 - if ok, let localId = op.localId, var entity = try await db.rdvDao.getByLocalId(localId) {
428 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
429 + let result = await api.updateRdv(id: serverId, jsonBody: op.payloadJson)
430 + if result.isOk, let localId = op.localId, var entity = try await db.rdvDao.getByLocalId(localId) {
348 431 entity.dirtyLocal = false
349 432 try await db.rdvDao.upsert(entity)
350 433 }
351 - return ok
434 + return Self.outcome(result)
352 435 case "delete":
353 - guard let serverId = op.serverId else { return false }
354 - return await api.deleteRdv(id: serverId).isOk
436 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
437 + return Self.outcome(await api.deleteRdv(id: serverId))
355 438 default:
356 - return true
439 + return PushOutcome(ok: true)
357 440 }
358 441 }
359 442
@@ -391,8 +474,7 @@ final class SyncEngine {
391 474 return try await handleReservationPushResult(result, op: op)
392 475 case "delete":
393 476 guard let serverId = op.serverId else { return PushOutcome(ok: false) }
394 - let ok = await api.deleteReservation(genre: cibleType, resourceId: cibleId, reservationId: serverId).isOk
395 - return PushOutcome(ok: ok)
477 + return Self.outcome(await api.deleteReservation(genre: cibleType, resourceId: cibleId, reservationId: serverId))
396 478 default:
397 479 return PushOutcome(ok: true)
398 480 }
@@ -420,13 +502,15 @@ final class SyncEngine {
420 502 }
421 503 return PushOutcome(ok: false, conflict: AgendaConflict(localReservationId: localId, message: message))
422 504 }
423 - return PushOutcome(ok: false)
505 + return Self.outcome(result)
424 506 }
425 507 }
426 508
427 509 // ---- pull ----
428 510
429 - private func applyPull(_ pull: SyncPullResponse) async throws {
511 + /// Applies the pull and returns the number of received items as a user would count
512 + /// them: workflows (referential) and tombstones (deletions) are excluded.
513 + private func applyPull(_ pull: SyncPullResponse) async throws -> Int {
430 514 for dto in pull.contacts { try await applyContact(dto) }
431 515 for dto in pull.entreprises { try await applyEntreprise(dto) }
432 516 for dto in pull.projets { try await applyProjet(dto) }
@@ -437,6 +521,8 @@ final class SyncEngine {
437 521 for dto in pull.reservations { try await agenda.applyReservationPull(dto) }
438 522 for dto in pull.indisponibilites { try await agenda.applyIndisponibilitePull(dto) }
439 523 for dto in pull.tombstones { try await applyTombstone(dto) }
524 + return pull.contacts.count + pull.entreprises.count + pull.projets.count + pull.taches.count
525 + + pull.interactions.count + pull.rdv.count + pull.reservations.count + pull.indisponibilites.count
440 526 }
441 527
442 528 private func applyContact(_ dto: ContactDto) async throws {
M ios/Card2vcf/Sync/SyncModels.swift
+24 -1
@@ -559,6 +559,7 @@ struct CreateContactRequest: Codable, Equatable {
559 559 var prenom: String = ""
560 560 var nom: String = ""
561 561 var entrepriseId: String? = nil
562 + var entrepriseNom: String? = nil
562 563 var fonction: String = ""
563 564 var emails: [ContactValeurDto] = []
564 565 var telephones: [ContactValeurDto] = []
@@ -568,7 +569,7 @@ struct CreateContactRequest: Codable, Equatable {
568 569 var tags: [String] = []
569 570
570 571 enum CodingKeys: String, CodingKey {
571 - case prenom, nom, entrepriseId, fonction, emails, telephones, notes, statut, etape, tags
572 + case prenom, nom, entrepriseId, entrepriseNom, fonction, emails, telephones, notes, statut, etape, tags
572 573 }
573 574 }
574 575
@@ -578,6 +579,7 @@ extension CreateContactRequest {
578 579 prenom = try c.decodeIfPresent(String.self, forKey: .prenom) ?? ""
579 580 nom = try c.decodeIfPresent(String.self, forKey: .nom) ?? ""
580 581 entrepriseId = try c.decodeIfPresent(String.self, forKey: .entrepriseId)
582 + entrepriseNom = try c.decodeIfPresent(String.self, forKey: .entrepriseNom)
581 583 fonction = try c.decodeIfPresent(String.self, forKey: .fonction) ?? ""
582 584 emails = try c.decodeIfPresent([ContactValeurDto].self, forKey: .emails) ?? []
583 585 telephones = try c.decodeIfPresent([ContactValeurDto].self, forKey: .telephones) ?? []
@@ -592,6 +594,7 @@ extension CreateContactRequest {
592 594 try c.encode(prenom, forKey: .prenom)
593 595 try c.encode(nom, forKey: .nom)
594 596 try encodeExplicitNull(entrepriseId, in: &c, forKey: .entrepriseId)
597 + try encodeExplicitNull(entrepriseNom, in: &c, forKey: .entrepriseNom)
595 598 try c.encode(fonction, forKey: .fonction)
596 599 try c.encode(emails, forKey: .emails)
597 600 try c.encode(telephones, forKey: .telephones)
@@ -602,6 +605,26 @@ extension CreateContactRequest {
602 605 }
603 606 }
604 607
608 +/// Create projet payload (mirror server `ProjetInput`, body of `/api/projets`).
609 +struct CreateProjetRequest: Codable, Equatable {
610 + var nom: String
611 + var description: String = ""
612 + var workflowId: String
613 +
614 + enum CodingKeys: String, CodingKey {
615 + case nom, description, workflowId
616 + }
617 +}
618 +
619 +extension CreateProjetRequest {
620 + init(from decoder: Decoder) throws {
621 + let c = try decoder.container(keyedBy: CodingKeys.self)
622 + nom = try c.decode(String.self, forKey: .nom)
623 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
624 + workflowId = try c.decode(String.self, forKey: .workflowId)
625 + }
626 +}
627 +
605 628 /// Create/update RDV payload (mirror server, body of `/api/rdv`).
606 629 struct RdvUpsertRequest: Codable, Equatable {
607 630 var titre: String = ""
M ios/Card2vcf/UI/Carnet/AlphabetRail.swift
+5 -2
@@ -22,6 +22,9 @@ struct AlphabetRail: View {
22 22 Text(String(letter))
23 23 .font(C2VFont.labelSmall)
24 24 .foregroundColor(C2VColor.ink)
25 + // Enlarged tap target: the whole rail width is tappable.
26 + .frame(maxWidth: .infinity)
27 + .contentShape(Rectangle())
25 28 }
26 29 .buttonStyle(.plain)
27 30 } else {
@@ -31,12 +34,12 @@ struct AlphabetRail: View {
31 34 }
32 35 }
33 36 .frame(maxHeight: .infinity)
34 - .padding(.vertical, 1)
37 + .padding(.vertical, 2)
35 38 }
36 39 }
37 40 .frame(maxWidth: .infinity, maxHeight: .infinity)
38 41 }
39 - .frame(width: 28)
42 + .frame(width: 32)
40 43 .padding(.vertical, 4)
41 44 }
42 45 }
M ios/Card2vcf/UI/Carnet/CarnetScreen.swift
+14 -2
@@ -29,7 +29,8 @@ struct CarnetScreen: View {
29 29 localAhead: syncViewModel.localCalendarAhead,
30 30 syncing: syncViewModel.syncing,
31 31 error: syncViewModel.error,
32 - onSyncClick: { syncViewModel.syncNow() }
32 + onSyncClick: { syncViewModel.syncNow() },
33 + summary: syncViewModel.syncSummary
33 34 )
34 35 Spacer().frame(height: 8)
35 36 content
@@ -128,7 +129,8 @@ struct CarnetScreen: View {
128 129 ForEach(section.contacts, id: \.id) { contact in
129 130 ContactRow(
130 131 contact: contact,
131 - duplicateCount: viewModel.duplicateCountById[contact.id] ?? 0
132 + duplicateCount: viewModel.duplicateCountById[contact.id] ?? 0,
133 + badge: viewModel.badges[contact.id]
132 134 ) {
133 135 onOpenContact(contact.id, viewModel.sort)
134 136 }
@@ -167,12 +169,22 @@ struct CarnetScreen: View {
167 169 private struct ContactRow: View {
168 170 let contact: CrmContactEntity
169 171 let duplicateCount: Int
172 + var badge: ContactSyncBadge? = nil
170 173 let onTap: () -> Void
171 174
172 175 var body: some View {
173 176 Button(action: onTap) {
174 177 VStack(spacing: 0) {
175 178 HStack(spacing: 10) {
179 + // Pastille de synchro : bleu = en attente, rouge = échec.
180 + if let badge {
181 + Circle()
182 + .fill(badge == .error ? C2VColor.brandError : C2VColor.link)
183 + .frame(width: 8, height: 8)
184 + .accessibilityLabel(
185 + badge == .error ? "Échec de synchronisation" : "En attente de synchronisation"
186 + )
187 + }
176 188 thumbnail
177 189 VStack(alignment: .leading, spacing: 2) {
178 190 Text(contactDisplayName(contact))
M ios/Card2vcf/UI/Carnet/CarnetViewModel.swift
+17 -4
@@ -11,17 +11,24 @@ final class CarnetViewModel: ObservableObject {
11 11 @Published private(set) var query: String = ""
12 12 @Published private(set) var sections: [AlphabetIndex.Section] = []
13 13 @Published private(set) var duplicateCountById: [Int64: Int] = [:]
14 + /// Sync badge per contact id (blue = queued, red = failed).
15 + @Published private(set) var badges: [Int64: ContactSyncBadge] = [:]
14 16
15 17 let repository: ContactRepository
18 + private let syncOpDao: SyncOpDao?
16 19
17 20 private var cancellables: Set<AnyCancellable> = []
18 21 private var refreshTask: Task<Void, Never>?
19 22
20 - init(repository: ContactRepository = ContactRepository(
21 - dao: Card2vcfDatabase.shared.crmContactDao,
22 - images: ContactImageStore()
23 - )) {
23 + init(
24 + repository: ContactRepository = ContactRepository(
25 + dao: Card2vcfDatabase.shared.crmContactDao,
26 + images: ContactImageStore()
27 + ),
28 + syncOpDao: SyncOpDao? = Card2vcfDatabase.shared.syncOpDao
29 + ) {
24 30 self.repository = repository
31 + self.syncOpDao = syncOpDao
25 32 NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
26 33 .receive(on: RunLoop.main)
27 34 .sink { [weak self] _ in self?.scheduleRefresh() }
@@ -62,6 +69,10 @@ final class CarnetViewModel: ObservableObject {
62 69 }
63 70 let exclusions = (try? await repository.getExclusions()) ?? []
64 71 let clusters = DuplicateDetector.clusters(contacts: all, exclusions: exclusions)
72 + var ops: [SyncOpEntity] = []
73 + if let syncOpDao {
74 + ops = (try? await syncOpDao.listAll()) ?? []
75 + }
65 76 guard !Task.isCancelled else { return }
66 77 sections = AlphabetIndex.group(contacts: list, sort: sort)
67 78 duplicateCountById = Dictionary(
@@ -69,10 +80,12 @@ final class CarnetViewModel: ObservableObject {
69 80 ($0.id, DuplicateDetector.duplicateCount($0.id, clusters: clusters))
70 81 }
71 82 )
83 + badges = ContactSyncBadges.compute(contacts: all, ops: ops)
72 84 } catch {
73 85 guard !Task.isCancelled else { return }
74 86 sections = []
75 87 duplicateCountById = [:]
88 + badges = [:]
76 89 }
77 90 }
78 91 }
A ios/Card2vcf/UI/Carnet/ContactSyncBadge.swift
+32 -0
@@ -0,0 +1,32 @@
1 +import Foundation
2 +
3 +// Port of ui/carnet/ContactSyncBadge.kt.
4 +
5 +/// Sync state of a contact row: queued (blue) or failed (red).
6 +enum ContactSyncBadge: Equatable {
7 + case pending
8 + case error
9 +}
10 +
11 +enum ContactSyncBadges {
12 + private static let contactTypes: Set<String> = ["contact", "contact_media"]
13 +
14 + /// Maps each contact having a queued sync op to its badge; ERROR wins.
15 + static func compute(
16 + contacts: [CrmContactEntity],
17 + ops: [SyncOpEntity]
18 + ) -> [Int64: ContactSyncBadge] {
19 + let contactOps = ops.filter { contactTypes.contains($0.entityType) }
20 + if contactOps.isEmpty { return [:] }
21 + var result: [Int64: ContactSyncBadge] = [:]
22 + for contact in contacts {
23 + let matched = contactOps.filter {
24 + $0.localId == contact.id
25 + || ($0.serverId != nil && $0.serverId == contact.serverId)
26 + }
27 + if matched.isEmpty { continue }
28 + result[contact.id] = matched.contains { $0.lastError != nil } ? .error : .pending
29 + }
30 + return result
31 + }
32 +}
M ios/Card2vcf/UI/Projets/ProjetsListScreen.swift
+90 -1
@@ -10,6 +10,8 @@ struct ProjetsListScreen: View {
10 10 let onSettings: () -> Void
11 11 @ObservedObject var syncViewModel: SyncChromeViewModel
12 12
13 + @State private var showCreateDialog = false
14 +
13 15 var body: some View {
14 16 VStack(spacing: 0) {
15 17 HStack(spacing: 0) {
@@ -44,7 +46,8 @@ struct ProjetsListScreen: View {
44 46 localAhead: syncViewModel.localCalendarAhead,
45 47 syncing: syncViewModel.syncing,
46 48 error: syncViewModel.error,
47 - onSyncClick: { syncViewModel.syncNow() }
49 + onSyncClick: { syncViewModel.syncNow() },
50 + summary: syncViewModel.syncSummary
48 51 )
49 52
50 53 Spacer().frame(height: 8)
@@ -72,6 +75,33 @@ struct ProjetsListScreen: View {
72 75 }
73 76 .padding(.horizontal, 18)
74 77 .background(C2VColor.fond)
78 + .overlay(alignment: .bottomTrailing) {
79 + Button(action: { showCreateDialog = true }) {
80 + Image(systemName: "plus")
81 + .foregroundColor(C2VColor.onPrimary)
82 + .frame(width: 56, height: 56)
83 + .background(C2VColor.ink)
84 + }
85 + .accessibilityLabel("Créer un projet")
86 + .padding(.trailing, 18)
87 + .padding(.bottom, 24)
88 + }
89 + .overlay {
90 + if showCreateDialog {
91 + CreateProjetDialog(
92 + workflows: viewModel.workflows,
93 + onCreate: { nom, description, workflowServerId in
94 + viewModel.createProjet(
95 + nom: nom,
96 + description: description,
97 + workflowServerId: workflowServerId
98 + )
99 + showCreateDialog = false
100 + },
101 + onDismiss: { showCreateDialog = false }
102 + )
103 + }
104 + }
75 105 .syncConflictDialog(
76 106 conflict: syncViewModel.conflictPending,
77 107 onConfirm: { syncViewModel.confirmConflictCancellation() },
@@ -83,6 +113,60 @@ struct ProjetsListScreen: View {
83 113 }
84 114 }
85 115
116 +private struct CreateProjetDialog: View {
117 + let workflows: [WorkflowEntity]
118 + let onCreate: (String, String, String) -> Void
119 + let onDismiss: () -> Void
120 +
121 + @State private var nom = ""
122 + @State private var description = ""
123 + @State private var workflowIndex = 0
124 +
125 + private var selected: WorkflowEntity? {
126 + workflows.indices.contains(workflowIndex) ? workflows[workflowIndex] : workflows.first
127 + }
128 +
129 + var body: some View {
130 + C2VDialog(
131 + title: "Créer un projet",
132 + confirmLabel: "Créer",
133 + dismissLabel: "Annuler",
134 + onConfirm: {
135 + guard nom.isNotBlank, let selected else { return }
136 + onCreate(nom, description, selected.serverId)
137 + },
138 + onDismiss: onDismiss
139 + ) {
140 + VStack(alignment: .leading, spacing: 8) {
141 + C2VTextField(label: "Nom du projet", text: $nom)
142 + C2VTextField(label: "Description", text: $description)
143 + if workflows.isEmpty {
144 + Text("Synchronisez d'abord pour récupérer les workflows")
145 + .font(C2VFont.bodyMedium)
146 + .foregroundColor(C2VColor.texteFaible)
147 + } else {
148 + Menu {
149 + ForEach(Array(workflows.enumerated()), id: \.offset) { index, workflow in
150 + Button(workflow.nom) { workflowIndex = index }
151 + }
152 + } label: {
153 + HStack(spacing: 4) {
154 + Text("Workflow : \(selected?.nom ?? "")")
155 + .font(C2VFont.labelMedium)
156 + .foregroundColor(C2VColor.ink)
157 + Image(systemName: "chevron.down")
158 + .foregroundColor(C2VColor.texteFaible)
159 + }
160 + .padding(.vertical, 8)
161 + .padding(.horizontal, 12)
162 + .overlay(Rectangle().stroke(C2VColor.hairline, lineWidth: 1))
163 + }
164 + }
165 + }
166 + }
167 + }
168 +}
169 +
86 170 private struct ProjetRow: View {
87 171 let projet: ProjetEntity
88 172 let onTap: () -> Void
@@ -99,6 +183,11 @@ private struct ProjetRow: View {
99 183 .font(C2VFont.bodyMedium)
100 184 .foregroundColor(C2VColor.texteFaible)
101 185 }
186 + if projet.serverId == nil {
187 + Text("En attente de synchro")
188 + .font(C2VFont.bodyMedium)
189 + .foregroundColor(C2VColor.texteFaible)
190 + }
102 191 }
103 192 .padding(.vertical, 10)
104 193 .frame(maxWidth: .infinity, alignment: .leading)
M ios/Card2vcf/UI/Projets/ProjetsViewModel.swift
+41 -1
@@ -6,12 +6,21 @@ import Foundation
6 6 @MainActor
7 7 final class ProjetsViewModel: ObservableObject {
8 8 @Published private(set) var projets: [ProjetEntity] = []
9 + @Published private(set) var workflows: [WorkflowEntity] = []
9 10
10 11 private let projetDao: ProjetDao
12 + private let workflowDao: WorkflowDao
13 + private let syncOpDao: SyncOpDao
11 14 private var cancellables: Set<AnyCancellable> = []
12 15
13 - init(projetDao: ProjetDao = Card2vcfDatabase.shared.projetDao) {
16 + init(
17 + projetDao: ProjetDao = Card2vcfDatabase.shared.projetDao,
18 + workflowDao: WorkflowDao = Card2vcfDatabase.shared.workflowDao,
19 + syncOpDao: SyncOpDao = Card2vcfDatabase.shared.syncOpDao
20 + ) {
14 21 self.projetDao = projetDao
22 + self.workflowDao = workflowDao
23 + self.syncOpDao = syncOpDao
15 24 NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
16 25 .receive(on: RunLoop.main)
17 26 .sink { [weak self] _ in Task { await self?.refresh() } }
@@ -20,5 +29,36 @@ final class ProjetsViewModel: ObservableObject {
20 29
21 30 func refresh() async {
22 31 projets = (try? await projetDao.fetchAll()) ?? []
32 + workflows = ((try? await workflowDao.listAll()) ?? []).sorted { $0.nom < $1.nom }
33 + }
34 +
35 + /// Creates the project locally and queues the sync op; the push fills `serverId`.
36 + func createProjet(nom: String, description: String, workflowServerId: String) {
37 + Task {
38 + await createProjetAsync(nom: nom, description: description, workflowServerId: workflowServerId)
39 + }
40 + }
41 +
42 + func createProjetAsync(nom: String, description: String, workflowServerId: String) async {
43 + let now = ContactRepository.currentMillis()
44 + var projet = ProjetEntity()
45 + projet.nom = nom.trimmingCharacters(in: .whitespacesAndNewlines)
46 + projet.description = description.trimmingCharacters(in: .whitespacesAndNewlines)
47 + projet.workflowServerId = workflowServerId
48 + projet.createdAt = now
49 + projet.updatedAt = now
50 + guard let localId = try? await projetDao.upsert(projet) else { return }
51 + var op = SyncOpEntity(entityType: "projet", op: "create")
52 + op.payloadJson = SyncJson.encodeToString(
53 + CreateProjetRequest(
54 + nom: projet.nom,
55 + description: projet.description,
56 + workflowId: workflowServerId
57 + )
58 + )
59 + op.localId = localId
60 + op.createdAt = now
61 + _ = try? await syncOpDao.insert(op)
62 + await refresh()
23 63 }
24 64 }
M ios/Card2vcf/UI/Scan/ContactDraftFieldsView.swift
+44 -6
@@ -8,30 +8,68 @@ struct ContactDraftFieldsView: View {
8 8 @Binding var card: ContactCard
9 9 var phonesAsList: Bool = false
10 10
11 + /// Kotlin `scan_champ_a_verifier` — hint under low-confidence OCR fields,
12 + /// cleared as soon as the user corrects the field.
13 + private static let aVerifier = "À vérifier — lecture incertaine"
14 +
11 15 var body: some View {
12 16 VStack(alignment: .leading, spacing: 10) {
13 - C2VTextField(label: "Nom complet", text: optionalBinding(\.fullName))
17 + C2VTextField(
18 + label: "Nom complet",
19 + text: optionalBinding(\.fullName, douteuxKey: "fullName"),
20 + supportingText: sousTexte("fullName")
21 + )
14 22 C2VTextField(label: "Prénom", text: optionalBinding(\.firstName))
15 23 C2VTextField(label: "Nom de famille", text: optionalBinding(\.lastName))
16 - C2VTextField(label: "Société", text: optionalBinding(\.company))
17 - C2VTextField(label: "Poste", text: optionalBinding(\.jobTitle))
24 + C2VTextField(
25 + label: "Société",
26 + text: optionalBinding(\.company, douteuxKey: "company"),
27 + supportingText: sousTexte("company")
28 + )
29 + C2VTextField(
30 + label: "Poste",
31 + text: optionalBinding(\.jobTitle, douteuxKey: "jobTitle"),
32 + supportingText: sousTexte("jobTitle")
33 + )
18 34 if phonesAsList {
19 35 EditableStringList(label: "Téléphone(s)", items: $card.phones)
20 36 EditableStringList(label: "E-mail(s)", items: $card.emails)
21 37 } else {
22 38 C2VTextField(label: "Téléphone(s)", text: joinedBinding(\.phones))
39 + .keyboardType(.phonePad)
23 40 C2VTextField(label: "E-mail(s)", text: joinedBinding(\.emails))
41 + .keyboardType(.emailAddress)
42 + .textInputAutocapitalization(.never)
24 43 }
25 44 C2VTextField(label: "Site web", text: optionalBinding(\.website))
26 - C2VTextField(label: "Adresse", text: optionalBinding(\.address))
45 + .keyboardType(.URL)
46 + .textInputAutocapitalization(.never)
47 + C2VTextField(
48 + label: "Adresse",
49 + text: optionalBinding(\.address, douteuxKey: "address"),
50 + supportingText: sousTexte("address")
51 + )
27 52 C2VTextField(label: "Note", text: optionalBinding(\.note), minLines: 2)
28 53 }
29 54 }
30 55
31 - private func optionalBinding(_ keyPath: WritableKeyPath<ContactCard, String?>) -> Binding<String> {
56 + private func sousTexte(_ cle: String) -> String? {
57 + card.champsDouteux.contains(cle) ? Self.aVerifier : nil
58 + }
59 +
60 + /// `douteuxKey`: editing the field clears its « à vérifier » flag.
61 + private func optionalBinding(
62 + _ keyPath: WritableKeyPath<ContactCard, String?>,
63 + douteuxKey: String? = nil
64 + ) -> Binding<String> {
32 65 Binding(
33 66 get: { card[keyPath: keyPath] ?? "" },
34 - set: { card[keyPath: keyPath] = $0.nilIfBlank }
67 + set: { value in
68 + card[keyPath: keyPath] = value.nilIfBlank
69 + if let douteuxKey {
70 + card.champsDouteux.remove(douteuxKey)
71 + }
72 + }
35 73 )
36 74 }
37 75
M ios/Card2vcf/UI/Settings/SettingsScreen.swift
+6 -5
@@ -61,8 +61,9 @@ struct SettingsScreen: View {
61 61 Text("Clé API : •••• (configurée)")
62 62 .font(C2VFont.bodyMedium)
63 63 .foregroundColor(C2VColor.texteFaible)
64 + // Destructive action: error color from the charte.
64 65 Button("Déconnecter") { viewModel.disconnect() }
65 - .buttonStyle(C2VTextButtonStyle())
66 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError))
66 67
67 68 C2VDivider()
68 69
@@ -103,7 +104,7 @@ struct SettingsScreen: View {
103 104 } else if let catalogueError = state.catalogueError {
104 105 Text(catalogueError)
105 106 .font(C2VFont.bodyMedium)
106 - .foregroundColor(C2VColor.ink)
107 + .foregroundColor(C2VColor.brandError)
107 108 } else {
108 109 ressourceGroup(
109 110 title: "Salles",
@@ -178,7 +179,7 @@ struct SettingsScreen: View {
178 179 if let error = state.error {
179 180 Text(error)
180 181 .font(C2VFont.bodyMedium)
181 - .foregroundColor(C2VColor.ink)
182 + .foregroundColor(C2VColor.brandError)
182 183 }
183 184 Button("Obtenir la clé") { viewModel.obtainKey() }
184 185 .buttonStyle(C2VPrimaryButtonStyle())
@@ -219,7 +220,7 @@ struct SettingsScreen: View {
219 220 if let retypeError = state.retypeError {
220 221 Text(retypeError)
221 222 .font(C2VFont.bodyMedium)
222 - .foregroundColor(C2VColor.ink)
223 + .foregroundColor(C2VColor.brandError)
223 224 }
224 225 }
225 226 }
@@ -233,7 +234,7 @@ struct SettingsScreen: View {
233 234 onConfirm: { viewModel.confirmRemoveBinding() },
234 235 onDismiss: { viewModel.dismissRemoveBinding() }
235 236 ) {
236 - Text("« \(pending.displayName) » ne sera plus synchronisé. Le calendrier reste visible dans l'app Agenda.")
237 + Text("« \(pending.displayName) » et ses événements seront supprimés de l'appareil. Vos RDV et réservations restent sur le serveur PicLead.")
237 238 .font(C2VFont.bodyLarge)
238 239 .foregroundColor(C2VColor.ink)
239 240 }
M ios/Card2vcf/UI/Settings/SettingsViewModel.swift
+23 -4
@@ -61,6 +61,10 @@ final class SettingsViewModel: ObservableObject {
61 61 static let KIND_VEHICULE = "vehicule"
62 62 static let MES_RDV_DISPLAY_NAME = "Card2vcf — Mes RDV"
63 63
64 + /// Shown when the calendar deletion fails (Agenda permission revoked meanwhile).
65 + static let CALENDAR_DELETE_ERROR =
66 + "Calendrier non supprimé : autorisation Agenda manquante. La liaison a bien été retirée."
67 +
64 68 @Published private(set) var state: SettingsUiState
65 69
66 70 private let credentialsStore: SyncCredentialsStore
@@ -264,12 +268,24 @@ final class SettingsViewModel: ObservableObject {
264 268 }
265 269 }
266 270
267 - /// Confirms the binding removal (« Retirer » in the confirmation dialog). Only the
268 - /// binding is removed (`CalendarBindingsStore.remove`): the bridge exposes no
269 - /// calendar deletion, so the created local calendar stays visible (empty) in the
270 - /// system calendar app.
271 + /// Confirms the binding removal (« Retirer » in the confirmation dialog): deletes the
272 + /// local calendar from the device (`CalendarBridge.deleteCalendar`, events cascade)
273 + /// **then** removes the binding (`CalendarBindingsStore.remove`). Server data (RDV,
274 + /// réservations) is untouched. If the deletion fails (Agenda permission revoked
275 + /// meanwhile), the binding is removed anyway and the user is told via `catalogueError`.
271 276 func confirmRemoveBinding() {
272 277 guard case .loggedIn(let s) = state, let pending = s.pendingRemoval else { return }
278 + let calendarId = calendarBindingsStore.list()
279 + .first { $0.kind == pending.kind && $0.serverResourceId == pending.serverResourceId }?
280 + .androidCalendarId
281 + var deleted = true
282 + if let calendarId {
283 + do {
284 + try calendarBridge.deleteCalendar(calendarId: calendarId)
285 + } catch {
286 + deleted = false
287 + }
288 + }
273 289 calendarBindingsStore.remove(kind: pending.kind, serverResourceId: pending.serverResourceId)
274 290 updateLoggedIn { current in
275 291 if pending.kind == AgendaSyncCoordinator.kindRdv {
@@ -280,6 +296,9 @@ final class SettingsViewModel: ObservableObject {
280 296 }
281 297 }
282 298 current.pendingRemoval = nil
299 + if !deleted {
300 + current.catalogueError = Self.CALENDAR_DELETE_ERROR
301 + }
283 302 }
284 303 }
285 304
M ios/Card2vcf/UI/Sync/SyncBanner.swift
+16 -4
@@ -2,17 +2,26 @@ import SwiftUI
2 2
3 3 // Port of ui/sync/SyncBanner.kt.
4 4
5 -/// Stacked hairline banners: A) changes on the server (or error); B) local calendar
6 -/// ahead (`localAhead` > 0, events/entities not pushed yet). Hidden when there is
7 -/// nothing to report.
5 +/// Stacked hairline banners: A) summary of the last sync (`summary`, with the
6 +/// verbatim server message when ops were refused); B) changes on the server (or
7 +/// error); C) local calendar ahead (`localAhead` > 0, events/entities not pushed
8 +/// yet). Hidden when there is nothing to report.
8 9 struct SyncBanner: View {
9 10 let pendingChanges: Int?
10 11 let localAhead: Int
11 12 let syncing: Bool
12 13 let error: String?
13 14 let onSyncClick: () -> Void
15 + var summary: SyncSummary? = nil
14 16
15 17 var body: some View {
18 + let summaryLabel: String? = summary.map { s in
19 + if s.failures > 0 {
20 + let message = s.firstFailureMessage ?? "refus du serveur sans détail"
21 + return "Synchro : \(s.pushed) envoyé(s) · \(s.received) reçu(s) · \(s.failures) échec(s) : \(message)"
22 + }
23 + return "Synchro : \(s.pushed) envoyé(s) · \(s.received) reçu(s)"
24 + }
16 25 let remoteLabel: String? = {
17 26 if let error { return error }
18 27 if let pendingChanges, pendingChanges > 0 {
@@ -20,8 +29,11 @@ struct SyncBanner: View {
20 29 }
21 30 return nil
22 31 }()
23 - if remoteLabel != nil || localAhead > 0 {
32 + if summaryLabel != nil || remoteLabel != nil || localAhead > 0 {
24 33 VStack(spacing: 0) {
34 + if let summaryLabel {
35 + SyncBannerRow(label: summaryLabel, syncing: syncing, onSyncClick: onSyncClick)
36 + }
25 37 if let remoteLabel {
26 38 SyncBannerRow(label: remoteLabel, syncing: syncing, onSyncClick: onSyncClick)
27 39 }
M ios/Card2vcf/UI/Sync/SyncChromeViewModel.swift
+18 -0
@@ -2,6 +2,15 @@ import Foundation
2 2
3 3 // Port of ui/sync/SyncChromeViewModel.kt.
4 4
5 +/// Summary of the last sync, rendered by `SyncBanner`. `firstFailureMessage` is
6 +/// the verbatim server message of the first refusal, `nil` when the server gave none.
7 +struct SyncSummary: Equatable {
8 + var pushed: Int
9 + var received: Int
10 + var failures: Int
11 + var firstFailureMessage: String? = nil
12 +}
13 +
5 14 /// Sync banner state: server-side changes + local calendar ahead on screen open
6 15 /// (`refreshStatus`, no auto-pull), manual sync on demand (`syncNow`: push then pull).
7 16 /// After `syncNow`, a conflicting reservation (409) triggers the « Annuler ma
@@ -14,6 +23,8 @@ final class SyncChromeViewModel: ObservableObject {
14 23 @Published private(set) var localCalendarAhead = 0
15 24 @Published private(set) var syncing = false
16 25 @Published private(set) var error: String?
26 + /// Summary of the last successful sync; cleared at the start of each `syncNow`.
27 + @Published private(set) var syncSummary: SyncSummary?
17 28 @Published private(set) var conflictPending: AgendaConflict?
18 29
19 30 private var pendingConflicts: [AgendaConflict] = []
@@ -81,11 +92,18 @@ final class SyncChromeViewModel: ObservableObject {
81 92 Task {
82 93 syncing = true
83 94 error = nil
95 + syncSummary = nil
84 96 let bindings = calendarBindingsStore.list()
85 97 do {
86 98 let result = try await engine.syncNow(bindings: bindings, bridge: calendarBridge)
87 99 if result.success {
88 100 setPendingConflicts(result.conflicts)
101 + syncSummary = SyncSummary(
102 + pushed: result.pushed,
103 + received: result.received,
104 + failures: result.pushFailures.count,
105 + firstFailureMessage: result.pushFailures.first?.message?.nilIfBlank
106 + )
89 107 let status = try await engine.checkStatus(
90 108 ressourcesQuery: AgendaSyncCoordinator.ressourcesQuery(bindings)
91 109 )
M ios/Card2vcf/UI/Theme.swift
+37 -10
@@ -1,25 +1,41 @@
1 1 import SwiftUI
2 2
3 -// Port of android ui/theme/{Color,Type,Theme}.kt + design.md.
4 -// Editorial black-on-white system: white canvas, near-black ink, 1 px hairline
5 -// dividers, square corners everywhere, link blue reserved for inline actions.
3 +// Port of android ui/theme/{Color,Type,Theme}.kt.
4 +// Palette PicLead — « ailiance brain » charte from the server (Server/tailwind.config.js):
5 +// white canvas, near-black ink, cool grays, mint accent, dedicated error red.
6 +// Link blue stays reserved for the « pending sync » dot (user spec).
6 7
7 8 enum C2VColor {
8 - static let ink = Color(hexRGB: 0x000000)
9 - static let inkSoft = Color(hexRGB: 0x1A1A1A)
10 - static let body = Color(hexRGB: 0x757575)
9 + static let ink = Color(hexRGB: 0x0F1115)
10 + static let charcoal = Color(hexRGB: 0x1F2937)
11 + static let slate = Color(hexRGB: 0x475467)
12 + static let steel = Color(hexRGB: 0x667085)
13 + static let stone = Color(hexRGB: 0x98A2B3)
11 14 static let canvas = Color(hexRGB: 0xFFFFFF)
12 - static let canvasSoft = Color(hexRGB: 0xF5F5F5)
13 - static let hairline = Color(hexRGB: 0xE0E0E0)
15 + static let surfaceTeinte = Color(hexRGB: 0xF4F5F7)
16 + static let surfaceSoft = Color(hexRGB: 0xFAFBFC)
17 + static let hairline = Color(hexRGB: 0xE4E6EA)
18 +
19 + static let brandGreen = Color(hexRGB: 0x00D4A4)
20 + static let brandGreenDeep = Color(hexRGB: 0x00B389)
21 + static let brandGreenSoft = Color(hexRGB: 0xE6FAF4)
22 + static let brandError = Color(hexRGB: 0xE5484D)
23 + static let brandErrorSoft = Color(hexRGB: 0xFDECEA)
24 + static let brandWarn = Color(hexRGB: 0xE89642)
25 +
26 + /// Blue reserved for the « not synchronized » dot (user spec).
14 27 static let link = Color(hexRGB: 0x057DBC)
15 28 static let onPrimary = Color(hexRGB: 0xFFFFFF)
16 29
17 30 // Historical aliases used by the migrated screens (Kotlin: Encre/Fond/…).
31 + static let inkSoft = charcoal
32 + static let body = steel
33 + static let canvasSoft = surfaceTeinte
18 34 static let encre = ink
19 35 static let fond = canvas
20 36 static let surface = canvas
21 37 static let bordure = hairline
22 - static let texteFaible = body
38 + static let texteFaible = steel
23 39 }
24 40
25 41 private extension Color {
@@ -143,6 +159,9 @@ struct C2VTextField: View {
143 159 var placeholder: String = ""
144 160 @Binding var text: String
145 161 var minLines: Int = 1
162 + /// Hint under the field (Compose `supportingText`), e.g. « À vérifier — lecture incertaine ».
163 + var supportingText: String? = nil
164 + var isError: Bool = false
146 165
147 166 @FocusState private var focused: Bool
148 167
@@ -163,8 +182,16 @@ struct C2VTextField: View {
163 182 .padding(.vertical, 10)
164 183 .background(C2VColor.canvas)
165 184 .overlay(
166 - Rectangle().stroke(focused ? C2VColor.ink : C2VColor.hairline, lineWidth: 1)
185 + Rectangle().stroke(
186 + isError ? C2VColor.brandError : (focused ? C2VColor.ink : C2VColor.hairline),
187 + lineWidth: 1
188 + )
167 189 )
190 + if let supportingText {
191 + Text(supportingText)
192 + .font(C2VFont.bodySmall)
193 + .foregroundColor(isError ? C2VColor.brandError : C2VColor.brandWarn)
194 + }
168 195 }
169 196 }
170 197
M ios/Card2vcfTests/AgendaSyncEngineTest.swift
+27 -0
@@ -99,6 +99,9 @@ final class AgendaSyncEngineTest: XCTestCase {
99 99
100 100 XCTAssertFalse(result.conflicts.isEmpty)
101 101 XCTAssertEqual(localId, result.conflicts.first?.localReservationId)
102 + // Le 409 réservation reste arbitré par la dialog dédiée : pas d'échec générique dans le résumé.
103 + XCTAssertTrue(result.pushFailures.isEmpty)
104 + XCTAssertEqual(0, result.pushed)
102 105 let stored = try await db.reservationDao.getByLocalId(localId)
103 106 XCTAssertEqual(true, stored?.conflictPending)
104 107 let pending = try await db.syncOpDao.listAll()
@@ -108,6 +111,30 @@ final class AgendaSyncEngineTest: XCTestCase {
108 111 XCTAssertEqual("salle-1", api.createReservationCalls[0].1)
109 112 }
110 113
114 + // ---- Motif d'une réservation créée dans l'app Calendrier ----
115 +
116 + func testAgendaEventCree_pousseLeTitreCommeMotif() async throws {
117 + let bridge = FakeCalendarBridge()
118 + let calendarId = try bridge.ensureLocalCalendar(displayName: "PicLead — Véhicule kangoo")
119 + // Cas réel : l'app Calendrier remplit le titre, pas la description.
120 + bridge.seedEvent(
121 + calendarId: calendarId,
122 + snap: CalendarEventSnapshot(title: "Course fournisseur", debutMs: 1_000, finMs: 2_000)
123 + )
124 + let bindings = [
125 + CalendarBinding(kind: "vehicule", serverResourceId: "v1", displayName: "Kangoo", androidCalendarId: calendarId),
126 + ]
127 +
128 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
129 +
130 + let stored = try await db.reservationDao.listByCible(cibleType: "vehicule", cibleId: "v1")
131 + XCTAssertEqual(1, stored.count)
132 + XCTAssertEqual("Course fournisseur", stored.first?.motif)
133 + XCTAssertEqual(1, api.createReservationCalls.count)
134 + let corps = api.createReservationCalls[0].2
135 + XCTAssertTrue(corps.contains(#""motif":"Course fournisseur""#), "motif attendu dans \(corps)")
136 + }
137 +
111 138 // ---- Abandon ----
112 139
113 140 func testAbandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() async throws {
A ios/Card2vcfTests/BlockGrouperTest.swift
+86 -0
@@ -0,0 +1,86 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class BlockGrouperTest: XCTestCase {
5 +
6 + private func ligne(
7 + _ text: String,
8 + top: Int,
9 + height: Int = 30,
10 + left: Int = 0,
11 + right: Int = 300,
12 + confidence: Float = 90
13 + ) -> OcrLine {
14 + OcrLine(text: text, box: OcrBox(left: left, top: top, right: right, bottom: top + height), confidence: confidence)
15 + }
16 +
17 + func testListeVideAucunBloc() {
18 + XCTAssertEqual([], BlockGrouper.group([]))
19 + }
20 +
21 + func testTroisPavesSeparesDonnentTroisBlocs() {
22 + let lines = [
23 + ligne("Sonia MARTIN", top: 100, height: 40),
24 + ligne("Directrice commerciale", top: 150),
25 + ligne("561 allée des Noisetiers", top: 400),
26 + ligne("69760 Limonest", top: 440),
27 + ligne("Tél. 04 75 35 12 34", top: 700),
28 + ]
29 + let blocs = BlockGrouper.group(lines)
30 + XCTAssertEqual(3, blocs.count)
31 + XCTAssertEqual(["Sonia MARTIN", "Directrice commerciale"], blocs[0].lines.map(\.text))
32 + XCTAssertEqual(["561 allée des Noisetiers", "69760 Limonest"], blocs[1].lines.map(\.text))
33 + XCTAssertEqual(["Tél. 04 75 35 12 34"], blocs[2].lines.map(\.text))
34 + }
35 +
36 + func testLignesAdresseContiguesGroupees() {
37 + let blocs = BlockGrouper.group([
38 + ligne("561 allée des Noisetiers", top: 100),
39 + ligne("69760 Limonest - France", top: 140),
40 + ])
41 + XCTAssertEqual(1, blocs.count)
42 + XCTAssertEqual("561 allée des Noisetiers\n69760 Limonest - France", blocs[0].text())
43 + }
44 +
45 + func testDeuxColonnesNonFusionneesMemeSiEntrelaceesVerticalement() {
46 + let gauche1 = ligne("561 allée des Noisetiers", top: 100, left: 0, right: 300)
47 + let droite1 = ligne("Tél. 04 75 35 12 34", top: 110, left: 600, right: 900)
48 + let gauche2 = ligne("69760 Limonest", top: 140, left: 0, right: 300)
49 + let droite2 = ligne("contact@voltea.fr", top: 150, left: 600, right: 900)
50 + let blocs = BlockGrouper.group([gauche1, droite1, gauche2, droite2])
51 + XCTAssertEqual(2, blocs.count)
52 + let textes = blocs.map { $0.lines.map(\.text) }
53 + XCTAssertTrue(textes.contains(["561 allée des Noisetiers", "69760 Limonest"]), "colonne gauche intacte: \(textes)")
54 + XCTAssertTrue(textes.contains(["Tél. 04 75 35 12 34", "contact@voltea.fr"]), "colonne droite intacte: \(textes)")
55 + }
56 +
57 + func testHauteurMedianeRobusteAUneLigneGeante() {
58 + // Logo géant (h=200) : la médiane reste ~30, donc l'écart de 60 px sous le logo coupe.
59 + let blocs = BlockGrouper.group([
60 + ligne("VOLTEA", top: 0, height: 200),
61 + ligne("Sonia Martin", top: 260),
62 + ligne("Directrice", top: 300),
63 + ])
64 + XCTAssertEqual(2, blocs.count)
65 + XCTAssertEqual(["VOLTEA"], blocs[0].lines.map(\.text))
66 + XCTAssertEqual(["Sonia Martin", "Directrice"], blocs[1].lines.map(\.text))
67 + }
68 +
69 + func testBlocsOrdonnesDeHautEnBasPuisDeGaucheADroite() {
70 + let basGauche = ligne("bas gauche", top: 500, left: 0, right: 200)
71 + let hautDroite = ligne("haut droite", top: 100, left: 600, right: 800)
72 + let hautGauche = ligne("haut gauche", top: 100, left: 0, right: 200)
73 + let blocs = BlockGrouper.group([basGauche, hautDroite, hautGauche])
74 + XCTAssertEqual(["haut gauche", "haut droite", "bas gauche"], blocs.map { $0.text() })
75 + }
76 +
77 + func testProprietesDeBloc() {
78 + let bloc = OcrBlock(lines: [
79 + OcrLine(text: "a", box: OcrBox(left: 10, top: 100, right: 200, bottom: 130), confidence: 80),
80 + OcrLine(text: "b", box: OcrBox(left: 20, top: 140, right: 300, bottom: 170), confidence: 60),
81 + ])
82 + XCTAssertEqual(OcrBox(left: 10, top: 100, right: 300, bottom: 170), bloc.box())
83 + XCTAssertEqual(70, bloc.avgConfidence())
84 + XCTAssertEqual("a\nb", bloc.text())
85 + }
86 +}
A ios/Card2vcfTests/ContactHeuristicParserSpatialTest.swift
+143 -0
@@ -0,0 +1,143 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactHeuristicParserSpatialTest: XCTestCase {
5 +
6 + private func ligne(
7 + _ text: String,
8 + top: Int,
9 + height: Int = 30,
10 + left: Int = 40,
11 + right: Int = 500,
12 + confidence: Float = 90
13 + ) -> OcrLine {
14 + OcrLine(text: text, box: OcrBox(left: left, top: top, right: right, bottom: top + height), confidence: confidence)
15 + }
16 +
17 + private func ocr(
18 + _ lines: [OcrLine],
19 + phones: [String] = [],
20 + emails: [String] = [],
21 + urls: [String] = []
22 + ) -> OcrResult {
23 + OcrResult(
24 + rawText: lines.map(\.text).joined(separator: "\n"),
25 + lines: lines.map(\.text),
26 + phones: phones,
27 + emails: emails,
28 + urls: urls,
29 + spatialLines: lines
30 + )
31 + }
32 +
33 + func testAdresseMultiLignesAssemblee() {
34 + let card = ContactHeuristicParser.parse(ocr([
35 + ligne("Sonia MARTIN", top: 100, height: 40),
36 + ligne("561 allée des Noisetiers", top: 400),
37 + ligne("69760 Limonest - France", top: 440),
38 + ]))
39 + XCTAssertEqual("561 allée des Noisetiers, 69760 Limonest - France", card.address)
40 + }
41 +
42 + func testNomEnGrosCaracteresSansMajusculesDetecte() {
43 + let card = ContactHeuristicParser.parse(ocr(
44 + [
45 + ligne("sonia martin", top: 100, height: 64),
46 + ligne("directrice commerciale", top: 180, height: 28),
47 + ],
48 + emails: ["sonia.martin@voltea.fr"]
49 + ))
50 + XCTAssertEqual("Martin", card.lastName)
51 + XCTAssertEqual("sonia", card.firstName?.lowercased())
52 + XCTAssertEqual("directrice commerciale", card.jobTitle)
53 + }
54 +
55 + func testSocieteDominanteEnMajusculesSepareeDuNom() {
56 + let card = ContactHeuristicParser.parse(ocr(
57 + [
58 + ligne("VOLTEA SOLUTIONS", top: 40, height: 50),
59 + ligne("Sonia MARTIN", top: 200, height: 34),
60 + ligne("Responsable grands comptes", top: 244, height: 28),
61 + ],
62 + emails: ["sonia.martin@voltea.fr"]
63 + ))
64 + XCTAssertEqual("VOLTEA SOLUTIONS", card.company)
65 + XCTAssertEqual("Sonia Martin", card.fullName)
66 + XCTAssertEqual("Responsable grands comptes", card.jobTitle)
67 + }
68 +
69 + func testNomToutEnMajusculesResteUnNomPasUneSociete() {
70 + // Très courant en France : « SONIA MARTIN » tout en capitales.
71 + let card = ContactHeuristicParser.parse(ocr(
72 + [
73 + ligne("SONIA MARTIN", top: 100, height: 34),
74 + ligne("Directrice commerciale", top: 150, height: 28),
75 + ],
76 + emails: ["sonia.martin@voltea.fr"]
77 + ))
78 + XCTAssertEqual("Sonia Martin", card.fullName)
79 + XCTAssertEqual("Martin", card.lastName)
80 + XCTAssertEqual("Voltea", card.company) // depuis le domaine email, pas le nom
81 + }
82 +
83 + func testDeuxColonnesSansMelangeAdresseTelephone() {
84 + let card = ContactHeuristicParser.parse(ocr(
85 + [
86 + ligne("Sonia MARTIN", top: 60, height: 40, left: 40, right: 900),
87 + ligne("12 rue des Fleurs", top: 400, left: 40, right: 380),
88 + ligne("75011 Paris", top: 440, left: 40, right: 380),
89 + ligne("Tél. 04 75 35 12 34", top: 405, left: 600, right: 980),
90 + ligne("sonia@voltea.fr", top: 445, left: 600, right: 980),
91 + ],
92 + phones: ["0475351234"],
93 + emails: ["sonia@voltea.fr"]
94 + ))
95 + XCTAssertEqual("12 rue des Fleurs, 75011 Paris", card.address)
96 + XCTAssertEqual(["0475351234"], card.phones)
97 + }
98 +
99 + func testLibelleTelephoneAvecValeurSurLaLigneSuivante() {
100 + let card = ContactHeuristicParser.parse(ocr([
101 + ligne("Sonia MARTIN", top: 60, height: 40),
102 + ligne("Tél. :", top: 300),
103 + ligne("04 75 35 12 34", top: 340),
104 + ]))
105 + XCTAssertTrue(card.phones.contains("0475351234"), "téléphone attendu: \(card.phones)")
106 + }
107 +
108 + func testChampsDouteuxMarquesSousLeSeuilDeConfiance() {
109 + let card = ContactHeuristicParser.parse(ocr([
110 + ligne("Sonia MARTIN", top: 100, height: 40, confidence: 45),
111 + ligne("561 allée des Noisetiers", top: 400, confidence: 92),
112 + ligne("69760 Limonest - France", top: 440, confidence: 91),
113 + ]))
114 + XCTAssertTrue(card.champsDouteux.contains("fullName"), "nom à 45% doit être douteux: \(card.champsDouteux)")
115 + XCTAssertFalse(card.champsDouteux.contains("address"), "adresse à ~91% doit être sûre: \(card.champsDouteux)")
116 + }
117 +
118 + func testFallbackSansBoxesAucunChampDouteux() {
119 + let card = ContactHeuristicParser.parse(
120 + OcrResult(rawText: "Sonia MARTIN\nDirectrice", lines: ["Sonia MARTIN", "Directrice"])
121 + )
122 + XCTAssertEqual([], card.champsDouteux)
123 + }
124 +
125 + func testCheminSpatialSeulementAvecBoxes() {
126 + let textes = [
127 + "Sonia MARTIN",
128 + "561 allée des Noisetiers",
129 + "69760 Limonest - France",
130 + ]
131 + let avecBoxes = ContactHeuristicParser.parse(ocr([
132 + ligne(textes[0], top: 100, height: 40),
133 + ligne(textes[1], top: 400),
134 + ligne(textes[2], top: 440),
135 + ]))
136 + let sansBoxes = ContactHeuristicParser.parse(
137 + OcrResult(rawText: textes.joined(separator: "\n"), lines: textes)
138 + )
139 + // Spatial : adresse complète ; legacy : une seule ligne (la plus longue).
140 + XCTAssertEqual("561 allée des Noisetiers, 69760 Limonest - France", avecBoxes.address)
141 + XCTAssertEqual("561 allée des Noisetiers", sansBoxes.address)
142 + }
143 +}
A ios/Card2vcfTests/ContactRepositoryTest.swift
+50 -0
@@ -0,0 +1,50 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port of the android `ContactRepositoryTest` additions: `updateFromCard`
5 +/// must not wipe the sync fields the draft does not carry.
6 +final class ContactRepositoryTest: XCTestCase {
7 + private var dao: InMemoryCrmContactDao!
8 + private var repo: ContactRepository!
9 +
10 + override func setUp() {
11 + super.setUp()
12 + dao = InMemoryCrmContactDao()
13 + repo = ContactRepository(dao: dao)
14 + }
15 +
16 + func testUpdateFromCardPreservesSyncFields() async throws {
17 + let id = try await repo.insertFromCard(ContactCard(fullName: "Ada", company: "Acme"))
18 + var stored = try await dao.getById(id)!
19 + stored.serverId = "srv-1"
20 + stored.entrepriseServerId = "ent-42"
21 + stored.statut = "client"
22 + stored.etape = "gagne"
23 + stored.tags = ["vip"]
24 + try await dao.update(stored)
25 +
26 + try await repo.updateFromCard(id: id, card: ContactCard(fullName: "Ada Lovelace", company: "Acme"))
27 +
28 + let updated = try await dao.getById(id)!
29 + XCTAssertEqual("Ada Lovelace", updated.fullName)
30 + XCTAssertEqual("srv-1", updated.serverId)
31 + XCTAssertEqual("ent-42", updated.entrepriseServerId)
32 + XCTAssertEqual("client", updated.statut)
33 + XCTAssertEqual("gagne", updated.etape)
34 + XCTAssertEqual(["vip"], updated.tags)
35 + }
36 +
37 + func testUpdateFromCardResetsEntrepriseLinkWhenCompanyChanges() async throws {
38 + let id = try await repo.insertFromCard(ContactCard(fullName: "Ada", company: "Acme"))
39 + var stored = try await dao.getById(id)!
40 + stored.serverId = "srv-1"
41 + stored.entrepriseServerId = "ent-42"
42 + try await dao.update(stored)
43 +
44 + try await repo.updateFromCard(id: id, card: ContactCard(fullName: "Ada", company: "Globex"))
45 +
46 + let updated = try await dao.getById(id)!
47 + XCTAssertEqual("srv-1", updated.serverId)
48 + XCTAssertNil(updated.entrepriseServerId)
49 + }
50 +}
A ios/Card2vcfTests/ContactSyncBadgesTest.swift
+53 -0
@@ -0,0 +1,53 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactSyncBadgesTest: XCTestCase {
5 + private let contact = CrmContactEntity(id: 1, fullName: "Ada", serverId: "srv-1")
6 +
7 + func testPendingWhenCreateOpMatchesByLocalId() {
8 + let ops = [SyncOpEntity(entityType: "contact", op: "create", localId: 1)]
9 + XCTAssertEqual(
10 + [1: ContactSyncBadge.pending],
11 + ContactSyncBadges.compute(contacts: [contact], ops: ops)
12 + )
13 + }
14 +
15 + func testPendingWhenUpdateOpMatchesByServerId() {
16 + let ops = [SyncOpEntity(entityType: "contact", op: "update", serverId: "srv-1")]
17 + XCTAssertEqual(
18 + [1: ContactSyncBadge.pending],
19 + ContactSyncBadges.compute(contacts: [contact], ops: ops)
20 + )
21 + }
22 +
23 + func testErrorWinsOverPending() {
24 + let ops = [
25 + SyncOpEntity(id: 1, entityType: "contact", op: "create", localId: 1),
26 + SyncOpEntity(id: 2, entityType: "contact", op: "update", serverId: "srv-1", lastError: "HTTP 500"),
27 + ]
28 + XCTAssertEqual(
29 + [1: ContactSyncBadge.error],
30 + ContactSyncBadges.compute(contacts: [contact], ops: ops)
31 + )
32 + }
33 +
34 + func testContactWithoutOpHasNoBadge() {
35 + let ops = [SyncOpEntity(entityType: "contact", op: "create", localId: 99)]
36 + XCTAssertTrue(ContactSyncBadges.compute(contacts: [contact], ops: ops).isEmpty)
37 + }
38 +
39 + func testNonContactOpsAreIgnored() {
40 + let ops = [SyncOpEntity(entityType: "projet", op: "create", localId: 1)]
41 + XCTAssertTrue(ContactSyncBadges.compute(contacts: [contact], ops: ops).isEmpty)
42 + }
43 +
44 + func testMediaOpsCountAsContactOps() {
45 + let ops = [
46 + SyncOpEntity(entityType: "contact_media", op: "create", localId: 1, serverId: "srv-1"),
47 + ]
48 + XCTAssertEqual(
49 + [1: ContactSyncBadge.pending],
50 + ContactSyncBadges.compute(contacts: [contact], ops: ops)
51 + )
52 + }
53 +}
M ios/Card2vcfTests/ContactSyncMapperTest.swift
+44 -0
@@ -38,6 +38,50 @@ final class ContactSyncMapperTest: XCTestCase {
38 38 XCTAssertEqual(["vip", "lyon"], request.tags)
39 39 }
40 40
41 + func testSendsEntrepriseNomWhenNoServerLink() throws {
42 + let entity = CrmContactEntity(
43 + fullName: "Ada",
44 + company: " Acme "
45 + )
46 +
47 + let payload = mapper.toCreatePayload(entity)
48 + let request = try SyncJson.decode(CreateContactRequest.self, from: payload)
49 +
50 + XCTAssertNil(request.entrepriseId)
51 + XCTAssertEqual("Acme", request.entrepriseNom)
52 + XCTAssertTrue(payload.contains(#""entreprise_nom""#))
53 + }
54 +
55 + func testOmitsEntrepriseNomWhenServerLinkExists() throws {
56 + let entity = CrmContactEntity(
57 + fullName: "Ada",
58 + company: "Acme",
59 + entrepriseServerId: "ent-42"
60 + )
61 +
62 + let request = try SyncJson.decode(
63 + CreateContactRequest.self,
64 + from: mapper.toCreatePayload(entity)
65 + )
66 +
67 + XCTAssertEqual("ent-42", request.entrepriseId)
68 + XCTAssertNil(request.entrepriseNom)
69 + }
70 +
71 + func testOmitsEntrepriseNomWhenCompanyBlank() throws {
72 + let entity = CrmContactEntity(
73 + fullName: "Ada",
74 + company: " "
75 + )
76 +
77 + let request = try SyncJson.decode(
78 + CreateContactRequest.self,
79 + from: mapper.toCreatePayload(entity)
80 + )
81 +
82 + XCTAssertNil(request.entrepriseNom)
83 + }
84 +
41 85 func testSplitsFullNameWhenFirstAndLastMissing() throws {
42 86 let entity = CrmContactEntity(
43 87 fullName: "Jean Paul Sartre",
M ios/Card2vcfTests/FakeAilianceApi.swift
+9 -1
@@ -19,8 +19,12 @@ final class FakeAilianceApi: AilianceApi {
19 19 var createRdvResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"rdv-generated"}"#)
20 20 var createRdvCalls: [String] = []
21 21
22 + var createProjetResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"projet-generated"}"#)
23 + var createProjetCalls: [String] = []
24 +
22 25 var statusQueries: [String?] = []
23 26 var pullQueries: [String?] = []
27 + var pullSince: [String] = []
24 28
25 29 var uploadCarteResult: AilianceApiClient.ApiResult<String> = .ok("{}")
26 30 var uploadPhotoResult: AilianceApiClient.ApiResult<String> = .ok("{}")
@@ -42,6 +46,7 @@ final class FakeAilianceApi: AilianceApi {
42 46
43 47 func syncPull(sinceIso: String, ressourcesQuery: String?) async -> AilianceApiClient.ApiResult<SyncPullResponse> {
44 48 pullQueries.append(ressourcesQuery)
49 + pullSince.append(sinceIso)
45 50 return pullResult
46 51 }
47 52
@@ -55,7 +60,10 @@ final class FakeAilianceApi: AilianceApi {
55 60 func createEntreprise(jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
56 61 func updateEntreprise(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
57 62 func deleteEntreprise(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
58 - func createProjet(jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
63 + func createProjet(jsonBody: String) async -> AilianceApiClient.ApiResult<String> {
64 + createProjetCalls.append(jsonBody)
65 + return createProjetResult
66 + }
59 67 func updateProjet(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
60 68 func deleteProjet(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
61 69 func createTache(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
M ios/Card2vcfTests/FakeCalendarBridge.swift
+11 -0
@@ -8,6 +8,10 @@ final class FakeCalendarBridge: CalendarBridge {
8 8 private var nextCalendarId: Int64 = 1
9 9 private var nextEventId: Int64 = 1
10 10 var deletedEventIds: [Int64] = []
11 + var deletedCalendarIds: [Int64] = []
12 +
13 + /// Simulates a revoked Calendar permission: `deleteCalendar` throws this when non-nil.
14 + var deleteCalendarFailure: Error? = nil
11 15
12 16 @discardableResult
13 17 func ensureLocalCalendar(displayName: String) throws -> Int64 {
@@ -18,6 +22,13 @@ final class FakeCalendarBridge: CalendarBridge {
18 22 return id
19 23 }
20 24
25 + func deleteCalendar(calendarId: Int64) throws {
26 + if let deleteCalendarFailure { throw deleteCalendarFailure }
27 + deletedCalendarIds.append(calendarId)
28 + calendars = calendars.filter { $0.value != calendarId }
29 + events.removeValue(forKey: calendarId)
30 + }
31 +
21 32 func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] {
22 33 guard let calendarEvents = events[calendarId] else { return [] }
23 34 return calendarEvents.keys.sorted().compactMap { calendarEvents[$0] }
M ios/Card2vcfTests/InMemoryCrmDatabase.swift
+6 -0
@@ -452,6 +452,12 @@ final class InMemorySyncOpDao: SyncOpDao {
452 452 rows.first { $0.id == id }
453 453 }
454 454
455 + func markFailure(id: Int64, error: String?) async throws {
456 + guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
457 + rows[index].attempts += 1
458 + rows[index].lastError = error
459 + }
460 +
455 461 func deleteById(_ id: Int64) async throws {
456 462 rows.removeAll { $0.id == id }
457 463 }
M ios/Card2vcfTests/OcrPostProcessingTest.swift
+30 -0
@@ -26,6 +26,36 @@ final class OcrPostProcessingTest: XCTestCase {
26 26 XCTAssertEqual(["ligne1", "ligne2"], r.lines)
27 27 }
28 28
29 + func testNeFusionnePasTelephoneEtCodePostalSurLignesVoisines() {
30 + let text = """
31 + Jean Dupont
32 + Tél. 04 75 35 12 34
33 + 07200 Aubenas
34 + """
35 + let r = OcrPostProcessor.process(text)
36 + XCTAssertEqual(["0475351234"], r.phones)
37 + }
38 +
39 + func testNeFusionnePasNumeroDeRueEtTelephone() {
40 + let text = """
41 + 245
42 + 04 75 35 12 34
43 + """
44 + let r = OcrPostProcessor.process(text)
45 + XCTAssertEqual(["0475351234"], r.phones)
46 + }
47 +
48 + func testIgnoreCodePostalSeul() {
49 + let r = OcrPostProcessor.process("07200 Aubenas")
50 + XCTAssertEqual([], r.phones)
51 + }
52 +
53 + func testRejetteLongueursImplausiblesSansIndicatif() {
54 + // 9 chiffres, 11 chiffres, ou 10 chiffres sans 0 initial : pas des numéros FR
55 + let r = OcrPostProcessor.process("123 45 67 89\n0 12 34 56 78 90\n61 23 45 67 89")
56 + XCTAssertEqual([], r.phones)
57 + }
58 +
29 59 func testExtraitCarteDimoCommeSurAppareil() {
30 60 let text = """
31 61 dimo
A ios/Card2vcfTests/ProjetsViewModelTest.swift
+38 -0
@@ -0,0 +1,38 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port of the android `ProjetsViewModelTest` (project creation from mobile).
5 +@MainActor
6 +final class ProjetsViewModelTest: XCTestCase {
7 +
8 + func testCreateProjetInsertsEntityAndSyncOp() async throws {
9 + let projetDao = InMemoryProjetDao()
10 + let workflowDao = InMemoryWorkflowDao()
11 + let syncOpDao = InMemorySyncOpDao()
12 + var workflow = WorkflowEntity(serverId: "wf-1")
13 + workflow.nom = "Vente"
14 + try await workflowDao.upsert(workflow)
15 + let viewModel = ProjetsViewModel(
16 + projetDao: projetDao,
17 + workflowDao: workflowDao,
18 + syncOpDao: syncOpDao
19 + )
20 +
21 + await viewModel.createProjetAsync(nom: " Salon ", description: " Stand A ", workflowServerId: "wf-1")
22 +
23 + let projets = try await projetDao.listAll()
24 + XCTAssertEqual(1, projets.count)
25 + XCTAssertEqual("Salon", projets[0].nom)
26 + XCTAssertEqual("Stand A", projets[0].description)
27 + XCTAssertEqual("wf-1", projets[0].workflowServerId)
28 +
29 + let ops = try await syncOpDao.listAll()
30 + XCTAssertEqual(1, ops.count)
31 + XCTAssertEqual("projet", ops[0].entityType)
32 + XCTAssertEqual("create", ops[0].op)
33 + XCTAssertEqual(projets[0].localId, ops[0].localId)
34 + let payload = try SyncJson.decode(CreateProjetRequest.self, from: ops[0].payloadJson)
35 + XCTAssertEqual("Salon", payload.nom)
36 + XCTAssertEqual("wf-1", payload.workflowId)
37 + }
38 +}
M ios/Card2vcfTests/SyncEngineTest.swift
+122 -0
@@ -221,6 +221,128 @@ final class SyncEngineTest: XCTestCase {
221 221 XCTAssertFalse(isDirectory.boolValue)
222 222 }
223 223
224 + func testWatermark_ignoreLorsqueLeServeurOuLeCompteChange() async throws {
225 + // Synchro réussie avec l'identité A : watermark posé à serverTime.
226 + api.pullResult = .ok(SyncPullResponse(serverTime: "2026-09-11T22:00:00Z"))
227 + let engineA = SyncEngine(api: api, db: db, serverIdentity: "https://prod.example|alice")
228 + _ = try await engineA.syncNow()
229 + let engineA2 = SyncEngine(api: api, db: db, serverIdentity: "https://prod.example|alice")
230 + _ = try await engineA2.syncNow()
231 + XCTAssertEqual("2026-09-11T22:00:00Z", api.pullSince.last)
232 +
233 + // Même base locale, mais serveur différent : le pull doit repartir de l'époque,
234 + // sinon les données plus anciennes que le watermark hérité ne redescendent jamais.
235 + let engineB = SyncEngine(api: api, db: db, serverIdentity: "https://demo.example|alice")
236 + _ = try await engineB.syncNow()
237 + XCTAssertEqual("1970-01-01T00:00:00Z", api.pullSince.last)
238 + }
239 +
240 + func testSyncNow_pushesProjetCreateOpAndMapsServerId() async throws {
241 + var projet = ProjetEntity()
242 + projet.nom = "Salon"
243 + projet.workflowServerId = "wf-1"
244 + let localId = try await db.projetDao.upsert(projet)
245 + var op = SyncOpEntity(entityType: "projet", op: "create")
246 + op.payloadJson = #"{"nom":"Salon","workflow_id":"wf-1"}"#
247 + op.localId = localId
248 + op.createdAt = 1
249 + _ = try await db.syncOpDao.insert(op)
250 +
251 + _ = try await engine.syncNow()
252 +
253 + XCTAssertEqual(1, api.createProjetCalls.count)
254 + let pending = try await db.syncOpDao.listAll()
255 + XCTAssertEqual(0, pending.count)
256 + let stored = try await db.projetDao.getByLocalId(localId)
257 + XCTAssertEqual("projet-generated", stored?.serverId)
258 + }
259 +
260 + // ---- Retour utilisateur : compteurs et échecs de push ----
261 +
262 + func testSyncNow_pushRefusedKeepsOpAndReportsServerMessage() async throws {
263 + var rdv = RdvEntity()
264 + rdv.titre = "Point client"
265 + rdv.dirtyLocal = true
266 + let localId = try await db.rdvDao.upsert(rdv)
267 + var op = SyncOpEntity(entityType: AgendaSyncCoordinator.kindRdv, op: "create")
268 + op.payloadJson = #"{"titre":"Point client"}"#
269 + op.localId = localId
270 + op.createdAt = 1
271 + _ = try await db.syncOpDao.insert(op)
272 + api.createRdvResult = .err(code: 409, message: "Réservation refusée : période en conflit avec Entretien")
273 +
274 + let result = try await engine.syncNow()
275 +
276 + XCTAssertTrue(result.success)
277 + let pending = try await db.syncOpDao.listAll()
278 + XCTAssertEqual(1, pending.count)
279 + XCTAssertEqual(0, result.pushed)
280 + XCTAssertEqual(1, result.pushFailures.count)
281 + let failure = result.pushFailures[0]
282 + XCTAssertEqual(AgendaSyncCoordinator.kindRdv, failure.entityType)
283 + XCTAssertEqual("create", failure.op)
284 + XCTAssertEqual(409, failure.code)
285 + XCTAssertEqual("Réservation refusée : période en conflit avec Entretien", failure.message)
286 + }
287 +
288 + func testSyncNow_pushFailureMarksOpWithAttemptsAndLastError() async throws {
289 + var contact = CrmContactEntity()
290 + contact.fullName = "Hors ligne"
291 + let localId = try await db.crmContactDao.insert(contact)
292 + var op = SyncOpEntity(entityType: "contact", op: "create")
293 + op.payloadJson = #"{"fullName":"Hors ligne"}"#
294 + op.localId = localId
295 + op.createdAt = 1
296 + _ = try await db.syncOpDao.insert(op)
297 + api.createContactResult = .err(code: 500, message: "Erreur interne.")
298 +
299 + _ = try await engine.syncNow()
300 +
301 + let ops = try await db.syncOpDao.listAll()
302 + XCTAssertEqual(1, ops.count)
303 + XCTAssertEqual(1, ops[0].attempts)
304 + XCTAssertEqual("Erreur interne.", ops[0].lastError)
305 +
306 + // Le push suivant réussit : l'op est dépilée, le badge disparaît.
307 + api.createContactResult = .ok(#"{"id":"srv-generated"}"#)
308 + _ = try await engine.syncNow()
309 + let afterRetry = try await db.syncOpDao.listAll()
310 + XCTAssertEqual(0, afterRetry.count)
311 + }
312 +
313 + func testSyncNow_reportsPushedAndReceivedCounts() async throws {
314 + var contact = CrmContactEntity()
315 + contact.fullName = "Nouveau"
316 + let localId = try await db.crmContactDao.insert(contact)
317 + var op = SyncOpEntity(entityType: "contact", op: "create")
318 + op.payloadJson = #"{"prenom":"A","nom":"B"}"#
319 + op.localId = localId
320 + op.createdAt = 1
321 + _ = try await db.syncOpDao.insert(op)
322 + api.pullResult = .ok(
323 + SyncPullResponse(
324 + serverTime: "2026-07-22T12:05:00Z",
325 + contacts: [
326 + ContactDto(id: "c1", prenom: "Jean", nom: "Dupont", creeLe: "2026-07-20T10:00:00Z"),
327 + ],
328 + interactions: [
329 + InteractionDto(id: "i1", contactId: "c1", sujet: "Appel", creeLe: "2026-07-22T09:00:00Z"),
330 + ],
331 + // Exclus du décompte utilisateur : référentiel et suppressions.
332 + tombstones: [
333 + TombstoneDto(entityType: "contact", id: "c-old", supprimeLe: "2026-07-22T12:00:00Z"),
334 + ],
335 + workflows: [WorkflowDto(id: "w1", nom: "Défaut", creeLe: "2026-07-20T10:00:00Z")]
336 + )
337 + )
338 +
339 + let result = try await engine.syncNow()
340 +
341 + XCTAssertEqual(1, result.pushed)
342 + XCTAssertEqual(2, result.received)
343 + XCTAssertTrue(result.pushFailures.isEmpty)
344 + }
345 +
224 346 func testSyncNow_skipsImageUploadWhenNoLocalFile() async throws {
225 347 var contact = CrmContactEntity()
226 348 contact.fullName = "Sans image"
M ios/Card2vcfTests/SyncSchemaDaoTest.swift
+24 -0
@@ -41,6 +41,30 @@ final class SyncSchemaDaoTest: XCTestCase {
41 41 XCTAssertEqual(0, empty.count)
42 42 }
43 43
44 + func testSyncOpMarkFailurePersistsAttemptsAndLastError() async throws {
45 + let dao = db.syncOpDao
46 + let id = try await dao.insert(
47 + SyncOpEntity(
48 + entityType: "contact",
49 + op: "create",
50 + payloadJson: #"{"fullName":"Ada"}"#,
51 + localId: 42,
52 + createdAt: 1000
53 + )
54 + )
55 +
56 + try await dao.markFailure(id: id, error: "Erreur interne.")
57 +
58 + let op = try await dao.getById(id)
59 + XCTAssertEqual(1, op?.attempts)
60 + XCTAssertEqual("Erreur interne.", op?.lastError)
61 +
62 + try await dao.markFailure(id: id, error: nil)
63 + let again = try await dao.getById(id)
64 + XCTAssertEqual(2, again?.attempts)
65 + XCTAssertNil(again?.lastError)
66 + }
67 +
44 68 func testTacheUpsertAndGetByServerId() async throws {
45 69 let dao = db.tacheDao
46 70 let localId = try await dao.upsert(
M ios/README.md
+13 -4
@@ -76,10 +76,19 @@ ios/
76 76 carnet Card2vcf).
77 77 - **Rotation manuelle de l'image de carte** (bouton 90° Android) : non
78 78 portée (le recadrage VisionKit redresse déjà la capture).
79 -- **Migration destructive** : même sémantique que
80 - `fallbackToDestructiveMigration` (schéma v3 ; toute autre version
81 - locale est réinitialisée) — exportez en VCF ou synchronisez avant
82 - mise à jour, comme sur Android.
79 +- **Migrations** : additives depuis v3 (v3→v4 : colonne `lastError` sur
80 + `sync_ops`, miroir de Room `MIGRATION_3_4`) ; toute version plus
81 + ancienne reste réinitialisée (filet destructif, comme sur Android).
82 +- **Prétraitement contraste (CLAHE)** : non porté — spécifique Tesseract ;
83 + Vision gère nativement les cartes colorées. L'extraction spatiale
84 + (BlockGrouper, champs douteux) est portée ; la confiance Vision (0-1,
85 + ramenée à 0-100) est plus grossière que celle de Tesseract, le seuil
86 + « à vérifier » (70) peut donc marquer un peu plus large.
87 +- **Refonte cosmétique Material 3** (coins arrondis, `ChampTexte`,
88 + chevrons de menus) : non répliquée à l'identique — iOS conserve son
89 + système éditorial carré ; la palette PicLead (menthe, rouge erreur,
90 + gris froids) et les signaux fonctionnels (pastilles de synchro, textes
91 + d'erreur, « À vérifier ») sont repris.
83 92 - **HTTP clair** : refusé par défaut (`ServerUrlPolicy`), la case
84 93 « Autoriser HTTP » reste nécessaire ; l'exception ATS globale du
85 94 `Info.plist` n'est effective qu'une fois cette case cochée.