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. |
GitRust