agenda Ios
EBO <eric.bouhana@softalys.com> committé le 2026-09-13 22:14
af26e0fa734ce41113330a9caa774c6e96fa7ed9
1 parent(s)
8 fichiers modifiés
+140
-34
M
ios/Card2vcf/Info.plist
+3
-3
@@ -5,7 +5,7 @@
| 5 | 5 | <key>CFBundleDevelopmentRegion</key> |
| 6 | 6 | <string>fr</string> |
| 7 | 7 | <key>CFBundleDisplayName</key> |
| 8 | - <string>Card2vcf</string> | |
| 8 | + <string>PicLead</string> | |
| 9 | 9 | <key>CFBundleExecutable</key> |
| 10 | 10 | <string>$(EXECUTABLE_NAME)</string> |
| 11 | 11 | <key>CFBundleIdentifier</key> |
@@ -33,9 +33,9 @@
| 33 | 33 | <key>NSContactsUsageDescription</key> |
| 34 | 34 | <string>Permet de créer un contact dans le carnet d'adresses du téléphone depuis une carte scannée.</string> |
| 35 | 35 | <key>NSCalendarsUsageDescription</key> |
| 36 | - <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec Projectiaon).</string> | |
| 36 | + <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec PicLead).</string> | |
| 37 | 37 | <key>NSCalendarsFullAccessUsageDescription</key> |
| 38 | - <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec Projectiaon).</string> | |
| 38 | + <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec PicLead).</string> | |
| 39 | 39 | <key>NSPhotoLibraryAddUsageDescription</key> |
| 40 | 40 | <string>Permet d'enregistrer l'image de la carte scannée si vous le demandez.</string> |
| 41 | 41 | <key>NSAppTransportSecurity</key> |
M
ios/Card2vcf/Sync/CalendarBridge.swift
+19
-2
@@ -46,6 +46,12 @@ enum CalendarEventTitles {
| 46 | 46 | /// Calendar-bridge contract used by `SyncEngine`/`AgendaSyncCoordinator` |
| 47 | 47 | /// (Kotlin `CalendarBridgeApi`); `EventKitCalendarBridge` implements it, fake in tests. |
| 48 | 48 | protocol CalendarBridge { |
| 49 | + /// True while the device still exposes the local calendar source. iOS hides it | |
| 50 | + /// when iCloud Calendar is on; the Settings flow then asks the user before | |
| 51 | + /// falling back to the account source (no Android equivalent: the local | |
| 52 | + /// calendar account always exists there). | |
| 53 | + func hasLocalSource() -> Bool | |
| 54 | + | |
| 49 | 55 | @discardableResult |
| 50 | 56 | func ensureLocalCalendar(displayName: String) throws -> Int64 |
| 51 | 57 |
@@ -102,7 +108,13 @@ final class EventKitCalendarBridge: CalendarBridge {
| 102 | 108 | } |
| 103 | 109 | } |
| 104 | 110 | |
| 111 | + func hasLocalSource() -> Bool { | |
| 112 | + store.sources.contains { $0.sourceType == .local } | |
| 113 | + } | |
| 114 | + | |
| 105 | 115 | /// Creates the local calendar `displayName` if missing, and returns its id. |
| 116 | + /// When iCloud hides the local source, the calendar lands in the default | |
| 117 | + /// account source — the Settings flow obtains user consent first. | |
| 106 | 118 | @discardableResult |
| 107 | 119 | func ensureLocalCalendar(displayName: String) throws -> Int64 { |
| 108 | 120 | if let existing = findCalendar(displayName: displayName) { |
@@ -198,10 +210,15 @@ final class EventKitCalendarBridge: CalendarBridge {
| 198 | 210 | event.timeZone = TimeZone.current |
| 199 | 211 | } |
| 200 | 212 | |
| 213 | + /// Finds an existing calendar by title among writable calendars, local source | |
| 214 | + /// first. A consented activation may have created it in the account source | |
| 215 | + /// (iCloud hiding the local one): matching it too avoids duplicates when a | |
| 216 | + /// binding is removed then re-enabled. | |
| 201 | 217 | private func findCalendar(displayName: String) -> EKCalendar? { |
| 202 | - store.calendars(for: .event).first { | |
| 203 | - $0.title == displayName && $0.source?.sourceType == .local | |
| 218 | + let candidates = store.calendars(for: .event).filter { | |
| 219 | + $0.title == displayName && $0.allowsContentModifications | |
| 204 | 220 | } |
| 221 | + return candidates.first { $0.source?.sourceType == .local } ?? candidates.first | |
| 205 | 222 | } |
| 206 | 223 | |
| 207 | 224 | private func calendar(for id: Int64) -> EKCalendar? { |
M
ios/Card2vcf/UI/Carnet/CarnetScreen.swift
+1
-1
@@ -50,7 +50,7 @@ struct CarnetScreen: View {
| 50 | 50 | |
| 51 | 51 | private var header: some View { |
| 52 | 52 | HStack(spacing: 0) { |
| 53 | - Text("Card2vcf".uppercased()) | |
| 53 | + Text("PicLead".uppercased()) | |
| 54 | 54 | .font(C2VFont.titleLarge) |
| 55 | 55 | .foregroundColor(C2VColor.ink) |
| 56 | 56 | Spacer() |
M
ios/Card2vcf/UI/Settings/SettingsScreen.swift
+23
-0
@@ -200,10 +200,33 @@ struct SettingsScreen: View {
| 200 | 200 | case .loggedIn(let state): |
| 201 | 201 | if let pending = state.pendingRemoval { |
| 202 | 202 | removeBindingDialog(pending) |
| 203 | + } else if let pending = state.pendingICloudActivation { | |
| 204 | + iCloudActivationDialog(pending) | |
| 203 | 205 | } |
| 204 | 206 | } |
| 205 | 207 | } |
| 206 | 208 | |
| 209 | + /// iOS only: iCloud Calendar hides the local source — the user chooses | |
| 210 | + /// between creating the calendar in their iCloud account or giving up. | |
| 211 | + private func iCloudActivationDialog(_ pending: PendingICloudActivation) -> some View { | |
| 212 | + C2VDialog( | |
| 213 | + title: "iCloud Agenda est actif", | |
| 214 | + confirmLabel: "Utiliser iCloud", | |
| 215 | + dismissLabel: "Annuler", | |
| 216 | + onConfirm: { viewModel.confirmICloudActivation() }, | |
| 217 | + onDismiss: { viewModel.cancelICloudActivation() } | |
| 218 | + ) { | |
| 219 | + Text( | |
| 220 | + "iOS masque le stockage local quand iCloud Agenda est activé : " | |
| 221 | + + "« \(pending.displayName) » ne peut pas rester uniquement sur l'appareil. " | |
| 222 | + + "Créer le calendrier dans votre compte iCloud (synchronisé sur vos appareils) ? " | |
| 223 | + + "Pour un calendrier 100 % local, désactivez Calendrier dans Réglages → iCloud, puis réessayez." | |
| 224 | + ) | |
| 225 | + .font(C2VFont.bodyLarge) | |
| 226 | + .foregroundColor(C2VColor.ink) | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 207 | 230 | private func retypeDialog(_ state: SettingsUiState.LoggedOut) -> some View { |
| 208 | 231 | C2VDialog( |
| 209 | 232 | title: "Retapez le mot de passe pour déchiffrer", |
M
ios/Card2vcf/UI/Settings/SettingsViewModel.swift
+69
-24
@@ -20,6 +20,14 @@ struct PendingCalendarRemoval: Equatable {
| 20 | 20 | var displayName: String |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | +/// Activation awaiting the user's choice when iCloud hides the local calendar | |
| 24 | +/// source: create the calendar in the account source (iCloud), or give up. | |
| 25 | +struct PendingICloudActivation: Equatable { | |
| 26 | + var kind: String | |
| 27 | + var serverResourceId: String? | |
| 28 | + var displayName: String | |
| 29 | +} | |
| 30 | + | |
| 23 | 31 | /// Paramètres screen state: logged in, or login form (+ pending retype). |
| 24 | 32 | enum SettingsUiState { |
| 25 | 33 | struct LoggedIn { |
@@ -31,6 +39,7 @@ enum SettingsUiState {
| 31 | 39 | var catalogueLoading = false |
| 32 | 40 | var catalogueError: String? = nil |
| 33 | 41 | var pendingRemoval: PendingCalendarRemoval? = nil |
| 42 | + var pendingICloudActivation: PendingICloudActivation? = nil | |
| 34 | 43 | } |
| 35 | 44 | |
| 36 | 45 | struct LoggedOut { |
@@ -59,7 +68,7 @@ final class SettingsViewModel: ObservableObject {
| 59 | 68 | static let KIND_SALLE = "salle" |
| 60 | 69 | static let KIND_MATERIEL = "materiel" |
| 61 | 70 | static let KIND_VEHICULE = "vehicule" |
| 62 | - static let MES_RDV_DISPLAY_NAME = "Card2vcf — Mes RDV" | |
| 71 | + static let MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV" | |
| 63 | 72 | |
| 64 | 73 | /// Shown when the calendar deletion fails (Agenda permission revoked meanwhile). |
| 65 | 74 | static let CALENDAR_DELETE_ERROR = |
@@ -216,15 +225,11 @@ final class SettingsViewModel: ObservableObject {
| 216 | 225 | func setMesRdvEnabled(_ enabled: Bool) { |
| 217 | 226 | guard case .loggedIn = state else { return } |
| 218 | 227 | if enabled { |
| 219 | - guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: Self.MES_RDV_DISPLAY_NAME) else { | |
| 220 | - return | |
| 221 | - } | |
| 222 | - calendarBindingsStore.upsert(CalendarBinding( | |
| 228 | + requestActivation( | |
| 223 | 229 | kind: AgendaSyncCoordinator.kindRdv, |
| 224 | - displayName: Self.MES_RDV_DISPLAY_NAME, | |
| 225 | - androidCalendarId: calendarId | |
| 226 | - )) | |
| 227 | - updateLoggedIn { $0.mesRdvLinked = true } | |
| 230 | + serverResourceId: nil, | |
| 231 | + displayName: Self.MES_RDV_DISPLAY_NAME | |
| 232 | + ) | |
| 228 | 233 | } else { |
| 229 | 234 | updateLoggedIn { |
| 230 | 235 | $0.pendingRemoval = PendingCalendarRemoval( |
@@ -243,20 +248,7 @@ final class SettingsViewModel: ObservableObject {
| 243 | 248 | return |
| 244 | 249 | } |
| 245 | 250 | if enabled { |
| 246 | - guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: item.displayName) else { | |
| 247 | - return | |
| 248 | - } | |
| 249 | - calendarBindingsStore.upsert(CalendarBinding( | |
| 250 | - kind: kind, | |
| 251 | - serverResourceId: serverResourceId, | |
| 252 | - displayName: item.displayName, | |
| 253 | - androidCalendarId: calendarId | |
| 254 | - )) | |
| 255 | - updateLoggedIn { current in | |
| 256 | - current.ressources = current.ressources.map { | |
| 257 | - $0.withLinked(kind: kind, serverResourceId: serverResourceId, linked: true) | |
| 258 | - } | |
| 259 | - } | |
| 251 | + requestActivation(kind: kind, serverResourceId: serverResourceId, displayName: item.displayName) | |
| 260 | 252 | } else { |
| 261 | 253 | updateLoggedIn { |
| 262 | 254 | $0.pendingRemoval = PendingCalendarRemoval( |
@@ -268,6 +260,59 @@ final class SettingsViewModel: ObservableObject {
| 268 | 260 | } |
| 269 | 261 | } |
| 270 | 262 | |
| 263 | + /// Activates directly when the local source exists; otherwise defers to the | |
| 264 | + /// « iCloud ou rien » user choice (`confirmICloudActivation` / `cancelICloudActivation`). | |
| 265 | + private func requestActivation(kind: String, serverResourceId: String?, displayName: String) { | |
| 266 | + guard calendarBridge.hasLocalSource() else { | |
| 267 | + updateLoggedIn { | |
| 268 | + $0.pendingICloudActivation = PendingICloudActivation( | |
| 269 | + kind: kind, | |
| 270 | + serverResourceId: serverResourceId, | |
| 271 | + displayName: displayName | |
| 272 | + ) | |
| 273 | + } | |
| 274 | + return | |
| 275 | + } | |
| 276 | + activateBinding(kind: kind, serverResourceId: serverResourceId, displayName: displayName) | |
| 277 | + } | |
| 278 | + | |
| 279 | + /// « Utiliser iCloud » : the calendar is created in the account source. | |
| 280 | + func confirmICloudActivation() { | |
| 281 | + guard case .loggedIn(let s) = state, let pending = s.pendingICloudActivation else { return } | |
| 282 | + updateLoggedIn { $0.pendingICloudActivation = nil } | |
| 283 | + activateBinding( | |
| 284 | + kind: pending.kind, | |
| 285 | + serverResourceId: pending.serverResourceId, | |
| 286 | + displayName: pending.displayName | |
| 287 | + ) | |
| 288 | + } | |
| 289 | + | |
| 290 | + /// « Annuler » : the toggle stays off, nothing is created. | |
| 291 | + func cancelICloudActivation() { | |
| 292 | + updateLoggedIn { $0.pendingICloudActivation = nil } | |
| 293 | + } | |
| 294 | + | |
| 295 | + private func activateBinding(kind: String, serverResourceId: String?, displayName: String) { | |
| 296 | + guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: displayName) else { | |
| 297 | + return | |
| 298 | + } | |
| 299 | + calendarBindingsStore.upsert(CalendarBinding( | |
| 300 | + kind: kind, | |
| 301 | + serverResourceId: serverResourceId, | |
| 302 | + displayName: displayName, | |
| 303 | + androidCalendarId: calendarId | |
| 304 | + )) | |
| 305 | + updateLoggedIn { current in | |
| 306 | + if kind == AgendaSyncCoordinator.kindRdv { | |
| 307 | + current.mesRdvLinked = true | |
| 308 | + } else if let serverResourceId { | |
| 309 | + current.ressources = current.ressources.map { | |
| 310 | + $0.withLinked(kind: kind, serverResourceId: serverResourceId, linked: true) | |
| 311 | + } | |
| 312 | + } | |
| 313 | + } | |
| 314 | + } | |
| 315 | + | |
| 271 | 316 | /// Confirms the binding removal (« Retirer » in the confirmation dialog): deletes the |
| 272 | 317 | /// local calendar from the device (`CalendarBridge.deleteCalendar`, events cascade) |
| 273 | 318 | /// **then** removes the binding (`CalendarBindingsStore.remove`). Server data (RDV, |
@@ -335,7 +380,7 @@ final class SettingsViewModel: ObservableObject {
| 335 | 380 | kind: kind, |
| 336 | 381 | serverResourceId: item.id, |
| 337 | 382 | nom: item.nom, |
| 338 | - displayName: "Card2vcf — \(label) \(item.nom)", | |
| 383 | + displayName: "PicLead — \(label) \(item.nom)", | |
| 339 | 384 | actif: item.actif, |
| 340 | 385 | linked: bindings.contains { $0.kind == kind && $0.serverResourceId == item.id } |
| 341 | 386 | ) |
M
ios/Card2vcfTests/FakeCalendarBridge.swift
+5
-0
@@ -13,6 +13,11 @@ final class FakeCalendarBridge: CalendarBridge {
| 13 | 13 | /// Simulates a revoked Calendar permission: `deleteCalendar` throws this when non-nil. |
| 14 | 14 | var deleteCalendarFailure: Error? = nil |
| 15 | 15 | |
| 16 | + /// Simulates iCloud Calendar hiding the local source when set to false. | |
| 17 | + var hasLocalSourceValue = true | |
| 18 | + | |
| 19 | + func hasLocalSource() -> Bool { hasLocalSourceValue } | |
| 20 | + | |
| 16 | 21 | @discardableResult |
| 17 | 22 | func ensureLocalCalendar(displayName: String) throws -> Int64 { |
| 18 | 23 | if let existing = calendars[displayName] { return existing } |
M
ios/README.md
+16
-2
@@ -1,9 +1,15 @@
| 1 | -# Card2vcf — iOS | |
| 1 | +# PicLead — iOS | |
| 2 | 2 | |
| 3 | 3 | Portage **natif Swift/SwiftUI** de l'application Android (voir le |
| 4 | 4 | [README racine](../README.md) pour la description fonctionnelle : |
| 5 | 5 | scan de carte de visite → OCR local → brouillon éditable → carnet |
| 6 | -local → export `.vcf`, sync Projectiaon optionnelle). | |
| 6 | +local → export `.vcf`, sync PicLead optionnelle). | |
| 7 | + | |
| 8 | +Rebranding au même niveau qu'Android : nom affiché **PicLead** et | |
| 9 | +bundle id `fr.ebii.piclead` (miroir de l'`applicationId`) ; le module, | |
| 10 | +la cible Xcode et les identifiants internes restent `Card2vcf`/ | |
| 11 | +`card2vcf` (miroir du `namespace` Android — clés, schéma, préfixe | |
| 12 | +`card2vcf:serverId=` partagés avec l'existant). | |
| 7 | 13 | |
| 8 | 14 | Comme sur Android, **l'OCR et le carnet restent 100 % sur l'appareil** : |
| 9 | 15 | aucune dépendance tierce, aucun service cloud — uniquement les |
@@ -92,3 +98,11 @@ ios/
| 92 | 98 | - **HTTP clair** : refusé par défaut (`ServerUrlPolicy`), la case |
| 93 | 99 | « Autoriser HTTP » reste nécessaire ; l'exception ATS globale du |
| 94 | 100 | `Info.plist` n'est effective qu'une fois cette case cochée. |
| 101 | +- **Source locale masquée par iCloud** (sans équivalent Android, où le | |
| 102 | + compte agenda local existe toujours) : quand iCloud Agenda est actif, | |
| 103 | + iOS n'expose plus le stockage local. À l'activation d'une liaison, | |
| 104 | + un dialogue laisse le choix : créer le calendrier dans le compte | |
| 105 | + iCloud de l'utilisateur (synchronisé sur ses appareils), ou annuler | |
| 106 | + (calendrier 100 % local possible en désactivant iCloud Agenda). | |
| 107 | + `findCalendar` retrouve les calendriers des deux sources pour éviter | |
| 108 | + les doublons au ré-appairage. |
M
ios/project.yml
+4
-2
@@ -17,7 +17,9 @@ targets:
| 17 | 17 | - path: Card2vcf |
| 18 | 18 | settings: |
| 19 | 19 | base: |
| 20 | - PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.card2vcf | |
| 20 | + # Identité d'installation rebrandée (miroir de l'`applicationId` Android | |
| 21 | + # fr.ebii.piclead) ; le module/cible reste Card2vcf (miroir du `namespace`). | |
| 22 | + PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.piclead | |
| 21 | 23 | INFOPLIST_FILE: Card2vcf/Info.plist |
| 22 | 24 | TARGETED_DEVICE_FAMILY: "1,2" |
| 23 | 25 | OTHER_LDFLAGS: -lsqlite3 |
@@ -31,5 +33,5 @@ targets:
| 31 | 33 | - target: Card2vcf |
| 32 | 34 | settings: |
| 33 | 35 | base: |
| 34 | - PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.card2vcf.tests | |
| 36 | + PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.piclead.tests | |
| 35 | 37 | GENERATE_INFOPLIST_FILE: "YES" |
GitRust