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"