initial commit

EBO <eric.bouhana@softalys.com> committé le 2026-08-28 18:25

656990b4f8f6eec35cb697841105be39c3befab4

326 fichiers modifiés +35404 -0
A .gitignore
+44 -0
@@ -0,0 +1,44 @@
1 +# Specs locales éventuelles — les plans versionnés restent dans docs/plans/
2 +docs/*
3 +!docs/plans/
4 +
5 +# Scratch superpowers (ledger SDD, briefs, paquets de revue, maquettes brainstorm) — local
6 +.superpowers/
7 +AGENTS.md
8 +.cursor/
9 +.omc/
10 +
11 +# Modèles (volumineux — jamais committés)
12 +*.gguf
13 +models/
14 +.models/
15 +
16 +# OS / éditeurs
17 +.DS_Store
18 +*.swp
19 +.idea/
20 +.vscode/
21 +
22 +# Dépendances éventuelles
23 +node_modules/
24 +__pycache__/
25 +*.pyc
26 +
27 +.venv/
28 +#workspace
29 +EBII_mobileVCF.code-workspace
30 +
31 +# QR codes — regenerated on-site with real SSID/pass/IP values
32 +staging/*.png
33 +
34 +# Android
35 +android/.gradle/
36 +android/.kotlin/
37 +android/build/
38 +android/app/build/
39 +android/tessdata-cache/
40 +android/app/src/main/assets/tesseract/tessdata/*.traineddata
41 +android/local.properties
42 +android/.idea/
43 +*.iml
44 +.worktrees/
A LICENSE
+661 -0
@@ -0,0 +1,661 @@
1 + GNU AFFERO GENERAL PUBLIC LICENSE
2 + Version 3, 19 November 2007
3 +
4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5 + Everyone is permitted to copy and distribute verbatim copies
6 + of this license document, but changing it is not allowed.
7 +
8 + Preamble
9 +
10 + The GNU Affero General Public License is a free, copyleft license for
11 +software and other kinds of works, specifically designed to ensure
12 +cooperation with the community in the case of network server software.
13 +
14 + The licenses for most software and other practical works are designed
15 +to take away your freedom to share and change the works. By contrast,
16 +our General Public Licenses are intended to guarantee your freedom to
17 +share and change all versions of a program--to make sure it remains free
18 +software for all its users.
19 +
20 + When we speak of free software, we are referring to freedom, not
21 +price. Our General Public Licenses are designed to make sure that you
22 +have the freedom to distribute copies of free software (and charge for
23 +them if you wish), that you receive source code or can get it if you
24 +want it, that you can change the software or use pieces of it in new
25 +free programs, and that you know you can do these things.
26 +
27 + Developers that use our General Public Licenses protect your rights
28 +with two steps: (1) assert copyright on the software, and (2) offer
29 +you this License which gives you legal permission to copy, distribute
30 +and/or modify the software.
31 +
32 + A secondary benefit of defending all users' freedom is that
33 +improvements made in alternate versions of the program, if they
34 +receive widespread use, become available for other developers to
35 +incorporate. Many developers of free software are heartened and
36 +encouraged by the resulting cooperation. However, in the case of
37 +software used on network servers, this result may fail to come about.
38 +The GNU General Public License permits making a modified version and
39 +letting the public access it on a server without ever releasing its
40 +source code to the public.
41 +
42 + The GNU Affero General Public License is designed specifically to
43 +ensure that, in such cases, the modified source code becomes available
44 +to the community. It requires the operator of a network server to
45 +provide the source code of the modified version running there to the
46 +users of that server. Therefore, public use of a modified version, on
47 +a publicly accessible server, gives the public access to the source
48 +code of the modified version.
49 +
50 + An older license, called the Affero General Public License and
51 +published by Affero, was designed to accomplish similar goals. This is
52 +a different license, not a version of the Affero GPL, but Affero has
53 +released a new version of the Affero GPL which permits relicensing under
54 +this license.
55 +
56 + The precise terms and conditions for copying, distribution and
57 +modification follow.
58 +
59 + TERMS AND CONDITIONS
60 +
61 + 0. Definitions.
62 +
63 + "This License" refers to version 3 of the GNU Affero General Public License.
64 +
65 + "Copyright" also means copyright-like laws that apply to other kinds of
66 +works, such as semiconductor masks.
67 +
68 + "The Program" refers to any copyrightable work licensed under this
69 +License. Each licensee is addressed as "you". "Licensees" and
70 +"recipients" may be individuals or organizations.
71 +
72 + To "modify" a work means to copy from or adapt all or part of the work
73 +in a fashion requiring copyright permission, other than the making of an
74 +exact copy. The resulting work is called a "modified version" of the
75 +earlier work or a work "based on" the earlier work.
76 +
77 + A "covered work" means either the unmodified Program or a work based
78 +on the Program.
79 +
80 + To "propagate" a work means to do anything with it that, without
81 +permission, would make you directly or secondarily liable for
82 +infringement under applicable copyright law, except executing it on a
83 +computer or modifying a private copy. Propagation includes copying,
84 +distribution (with or without modification), making available to the
85 +public, and in some countries other activities as well.
86 +
87 + To "convey" a work means any kind of propagation that enables other
88 +parties to make or receive copies. Mere interaction with a user through
89 +a computer network, with no transfer of a copy, is not conveying.
90 +
91 + An interactive user interface displays "Appropriate Legal Notices"
92 +to the extent that it includes a convenient and prominently visible
93 +feature that (1) displays an appropriate copyright notice, and (2)
94 +tells the user that there is no warranty for the work (except to the
95 +extent that warranties are provided), that licensees may convey the
96 +work under this License, and how to view a copy of this License. If
97 +the interface presents a list of user commands or options, such as a
98 +menu, a prominent item in the list meets this criterion.
99 +
100 + 1. Source Code.
101 +
102 + The "source code" for a work means the preferred form of the work
103 +for making modifications to it. "Object code" means any non-source
104 +form of a work.
105 +
106 + A "Standard Interface" means an interface that either is an official
107 +standard defined by a recognized standards body, or, in the case of
108 +interfaces specified for a particular programming language, one that
109 +is widely used among developers working in that language.
110 +
111 + The "System Libraries" of an executable work include anything, other
112 +than the work as a whole, that (a) is included in the normal form of
113 +packaging a Major Component, but which is not part of that Major
114 +Component, and (b) serves only to enable use of the work with that
115 +Major Component, or to implement a Standard Interface for which an
116 +implementation is available to the public in source code form. A
117 +"Major Component", in this context, means a major essential component
118 +(kernel, window system, and so on) of the specific operating system
119 +(if any) on which the executable work runs, or a compiler used to
120 +produce the work, or an object code interpreter used to run it.
121 +
122 + The "Corresponding Source" for a work in object code form means all
123 +the source code needed to generate, install, and (for an executable
124 +work) run the object code and to modify the work, including scripts to
125 +control those activities. However, it does not include the work's
126 +System Libraries, or general-purpose tools or generally available free
127 +programs which are used unmodified in performing those activities but
128 +which are not part of the work. For example, Corresponding Source
129 +includes interface definition files associated with source files for
130 +the work, and the source code for shared libraries and dynamically
131 +linked subprograms that the work is specifically designed to require,
132 +such as by intimate data communication or control flow between those
133 +subprograms and other parts of the work.
134 +
135 + The Corresponding Source need not include anything that users
136 +can regenerate automatically from other parts of the Corresponding
137 +Source.
138 +
139 + The Corresponding Source for a work in source code form is that
140 +same work.
141 +
142 + 2. Basic Permissions.
143 +
144 + All rights granted under this License are granted for the term of
145 +copyright on the Program, and are irrevocable provided the stated
146 +conditions are met. This License explicitly affirms your unlimited
147 +permission to run the unmodified Program. The output from running a
148 +covered work is covered by this License only if the output, given its
149 +content, constitutes a covered work. This License acknowledges your
150 +rights of fair use or other equivalent, as provided by copyright law.
151 +
152 + You may make, run and propagate covered works that you do not
153 +convey, without conditions so long as your license otherwise remains
154 +in force. You may convey covered works to others for the sole purpose
155 +of having them make modifications exclusively for you, or provide you
156 +with facilities for running those works, provided that you comply with
157 +the terms of this License in conveying all material for which you do
158 +not control copyright. Those thus making or running the covered works
159 +for you must do so exclusively on your behalf, under your direction
160 +and control, on terms that prohibit them from making any copies of
161 +your copyrighted material outside their relationship with you.
162 +
163 + Conveying under any other circumstances is permitted solely under
164 +the conditions stated below. Sublicensing is not allowed; section 10
165 +makes it unnecessary.
166 +
167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 +
169 + No covered work shall be deemed part of an effective technological
170 +measure under any applicable law fulfilling obligations under article
171 +11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 +similar laws prohibiting or restricting circumvention of such
173 +measures.
174 +
175 + When you convey a covered work, you waive any legal power to forbid
176 +circumvention of technological measures to the extent such circumvention
177 +is effected by exercising rights under this License with respect to
178 +the covered work, and you disclaim any intention to limit operation or
179 +modification of the work as a means of enforcing, against the work's
180 +users, your or third parties' legal rights to forbid circumvention of
181 +technological measures.
182 +
183 + 4. Conveying Verbatim Copies.
184 +
185 + You may convey verbatim copies of the Program's source code as you
186 +receive it, in any medium, provided that you conspicuously and
187 +appropriately publish on each copy an appropriate copyright notice;
188 +keep intact all notices stating that this License and any
189 +non-permissive terms added in accord with section 7 apply to the code;
190 +keep intact all notices of the absence of any warranty; and give all
191 +recipients a copy of this License along with the Program.
192 +
193 + You may charge any price or no price for each copy that you convey,
194 +and you may offer support or warranty protection for a fee.
195 +
196 + 5. Conveying Modified Source Versions.
197 +
198 + You may convey a work based on the Program, or the modifications to
199 +produce it from the Program, in the form of source code under the
200 +terms of section 4, provided that you also meet all of these conditions:
201 +
202 + a) The work must carry prominent notices stating that you modified
203 + it, and giving a relevant date.
204 +
205 + b) The work must carry prominent notices stating that it is
206 + released under this License and any conditions added under section
207 + 7. This requirement modifies the requirement in section 4 to
208 + "keep intact all notices".
209 +
210 + c) You must license the entire work, as a whole, under this
211 + License to anyone who comes into possession of a copy. This
212 + License will therefore apply, along with any applicable section 7
213 + additional terms, to the whole of the work, and all its parts,
214 + regardless of how they are packaged. This License gives no
215 + permission to license the work in any other way, but it does not
216 + invalidate such permission if you have separately received it.
217 +
218 + d) If the work has interactive user interfaces, each must display
219 + Appropriate Legal Notices; however, if the Program has interactive
220 + interfaces that do not display Appropriate Legal Notices, your
221 + work need not make them do so.
222 +
223 + A compilation of a covered work with other separate and independent
224 +works, which are not by their nature extensions of the covered work,
225 +and which are not combined with it such as to form a larger program,
226 +in or on a volume of a storage or distribution medium, is called an
227 +"aggregate" if the compilation and its resulting copyright are not
228 +used to limit the access or legal rights of the compilation's users
229 +beyond what the individual works permit. Inclusion of a covered work
230 +in an aggregate does not cause this License to apply to the other
231 +parts of the aggregate.
232 +
233 + 6. Conveying Non-Source Forms.
234 +
235 + You may convey a covered work in object code form under the terms
236 +of sections 4 and 5, provided that you also convey the
237 +machine-readable Corresponding Source under the terms of this License,
238 +in one of these ways:
239 +
240 + a) Convey the object code in, or embodied in, a physical product
241 + (including a physical distribution medium), accompanied by the
242 + Corresponding Source fixed on a durable physical medium
243 + customarily used for software interchange.
244 +
245 + b) Convey the object code in, or embodied in, a physical product
246 + (including a physical distribution medium), accompanied by a
247 + written offer, valid for at least three years and valid for as
248 + long as you offer spare parts or customer support for that product
249 + model, to give anyone who possesses the object code either (1) a
250 + copy of the Corresponding Source for all the software in the
251 + product that is covered by this License, on a durable physical
252 + medium customarily used for software interchange, for a price no
253 + more than your reasonable cost of physically performing this
254 + conveying of source, or (2) access to copy the
255 + Corresponding Source from a network server at no charge.
256 +
257 + c) Convey individual copies of the object code with a copy of the
258 + written offer to provide the Corresponding Source. This
259 + alternative is allowed only occasionally and noncommercially, and
260 + only if you received the object code with such an offer, in accord
261 + with subsection 6b.
262 +
263 + d) Convey the object code by offering access from a designated
264 + place (gratis or for a charge), and offer equivalent access to the
265 + Corresponding Source in the same way through the same place at no
266 + further charge. You need not require recipients to copy the
267 + Corresponding Source along with the object code. If the place to
268 + copy the object code is a network server, the Corresponding Source
269 + may be on a different server (operated by you or a third party)
270 + that supports equivalent copying facilities, provided you maintain
271 + clear directions next to the object code saying where to find the
272 + Corresponding Source. Regardless of what server hosts the
273 + Corresponding Source, you remain obligated to ensure that it is
274 + available for as long as needed to satisfy these requirements.
275 +
276 + e) Convey the object code using peer-to-peer transmission, provided
277 + you inform other peers where the object code and Corresponding
278 + Source of the work are being offered to the general public at no
279 + charge under subsection 6d.
280 +
281 + A separable portion of the object code, whose source code is excluded
282 +from the Corresponding Source as a System Library, need not be
283 +included in conveying the object code work.
284 +
285 + A "User Product" is either (1) a "consumer product", which means any
286 +tangible personal property which is normally used for personal, family,
287 +or household purposes, or (2) anything designed or sold for incorporation
288 +into a dwelling. In determining whether a product is a consumer product,
289 +doubtful cases shall be resolved in favor of coverage. For a particular
290 +product received by a particular user, "normally used" refers to a
291 +typical or common use of that class of product, regardless of the status
292 +of the particular user or of the way in which the particular user
293 +actually uses, or expects or is expected to use, the product. A product
294 +is a consumer product regardless of whether the product has substantial
295 +commercial, industrial or non-consumer uses, unless such uses represent
296 +the only significant mode of use of the product.
297 +
298 + "Installation Information" for a User Product means any methods,
299 +procedures, authorization keys, or other information required to install
300 +and execute modified versions of a covered work in that User Product from
301 +a modified version of its Corresponding Source. The information must
302 +suffice to ensure that the continued functioning of the modified object
303 +code is in no case prevented or interfered with solely because
304 +modification has been made.
305 +
306 + If you convey an object code work under this section in, or with, or
307 +specifically for use in, a User Product, and the conveying occurs as
308 +part of a transaction in which the right of possession and use of the
309 +User Product is transferred to the recipient in perpetuity or for a
310 +fixed term (regardless of how the transaction is characterized), the
311 +Corresponding Source conveyed under this section must be accompanied
312 +by the Installation Information. But this requirement does not apply
313 +if neither you nor any third party retains the ability to install
314 +modified object code on the User Product (for example, the work has
315 +been installed in ROM).
316 +
317 + The requirement to provide Installation Information does not include a
318 +requirement to continue to provide support service, warranty, or updates
319 +for a work that has been modified or installed by the recipient, or for
320 +the User Product in which it has been modified or installed. Access to a
321 +network may be denied when the modification itself materially and
322 +adversely affects the operation of the network or violates the rules and
323 +protocols for communication across the network.
324 +
325 + Corresponding Source conveyed, and Installation Information provided,
326 +in accord with this section must be in a format that is publicly
327 +documented (and with an implementation available to the public in
328 +source code form), and must require no special password or key for
329 +unpacking, reading or copying.
330 +
331 + 7. Additional Terms.
332 +
333 + "Additional permissions" are terms that supplement the terms of this
334 +License by making exceptions from one or more of its conditions.
335 +Additional permissions that are applicable to the entire Program shall
336 +be treated as though they were included in this License, to the extent
337 +that they are valid under applicable law. If additional permissions
338 +apply only to part of the Program, that part may be used separately
339 +under those permissions, but the entire Program remains governed by
340 +this License without regard to the additional permissions.
341 +
342 + When you convey a copy of a covered work, you may at your option
343 +remove any additional permissions from that copy, or from any part of
344 +it. (Additional permissions may be written to require their own
345 +removal in certain cases when you modify the work.) You may place
346 +additional permissions on material, added by you to a covered work,
347 +for which you have or can give appropriate copyright permission.
348 +
349 + Notwithstanding any other provision of this License, for material you
350 +add to a covered work, you may (if authorized by the copyright holders of
351 +that material) supplement the terms of this License with terms:
352 +
353 + a) Disclaiming warranty or limiting liability differently from the
354 + terms of sections 15 and 16 of this License; or
355 +
356 + b) Requiring preservation of specified reasonable legal notices or
357 + author attributions in that material or in the Appropriate Legal
358 + Notices displayed by works containing it; or
359 +
360 + c) Prohibiting misrepresentation of the origin of that material, or
361 + requiring that modified versions of such material be marked in
362 + reasonable ways as different from the original version; or
363 +
364 + d) Limiting the use for publicity purposes of names of licensors or
365 + authors of the material; or
366 +
367 + e) Declining to grant rights under trademark law for use of some
368 + trade names, trademarks, or service marks; or
369 +
370 + f) Requiring indemnification of licensors and authors of that
371 + material by anyone who conveys the material (or modified versions of
372 + it) with contractual assumptions of liability to the recipient, for
373 + any liability that these contractual assumptions directly impose on
374 + those licensors and authors.
375 +
376 + All other non-permissive additional terms are considered "further
377 +restrictions" within the meaning of section 10. If the Program as you
378 +received it, or any part of it, contains a notice stating that it is
379 +governed by this License along with a term that is a further
380 +restriction, you may remove that term. If a license document contains
381 +a further restriction but permits relicensing or conveying under this
382 +License, you may add to a covered work material governed by the terms
383 +of that license document, provided that the further restriction does
384 +not survive such relicensing or conveying.
385 +
386 + If you add terms to a covered work in accord with this section, you
387 +must place, in the relevant source files, a statement of the
388 +additional terms that apply to those files, or a notice indicating
389 +where to find the applicable terms.
390 +
391 + Additional terms, permissive or non-permissive, may be stated in the
392 +form of a separately written license, or stated as exceptions;
393 +the above requirements apply either way.
394 +
395 + 8. Termination.
396 +
397 + You may not propagate or modify a covered work except as expressly
398 +provided under this License. Any attempt otherwise to propagate or
399 +modify it is void, and will automatically terminate your rights under
400 +this License (including any patent licenses granted under the third
401 +paragraph of section 11).
402 +
403 + However, if you cease all violation of this License, then your
404 +license from a particular copyright holder is reinstated (a)
405 +provisionally, unless and until the copyright holder explicitly and
406 +finally terminates your license, and (b) permanently, if the copyright
407 +holder fails to notify you of the violation by some reasonable means
408 +prior to 60 days after the cessation.
409 +
410 + Moreover, your license from a particular copyright holder is
411 +reinstated permanently if the copyright holder notifies you of the
412 +violation by some reasonable means, this is the first time you have
413 +received notice of violation of this License (for any work) from that
414 +copyright holder, and you cure the violation prior to 30 days after
415 +your receipt of the notice.
416 +
417 + Termination of your rights under this section does not terminate the
418 +licenses of parties who have received copies or rights from you under
419 +this License. If your rights have been terminated and not permanently
420 +reinstated, you do not qualify to receive new licenses for the same
421 +material under section 10.
422 +
423 + 9. Acceptance Not Required for Having Copies.
424 +
425 + You are not required to accept this License in order to receive or
426 +run a copy of the Program. Ancillary propagation of a covered work
427 +occurring solely as a consequence of using peer-to-peer transmission
428 +to receive a copy likewise does not require acceptance. However,
429 +nothing other than this License grants you permission to propagate or
430 +modify any covered work. These actions infringe copyright if you do
431 +not accept this License. Therefore, by modifying or propagating a
432 +covered work, you indicate your acceptance of this License to do so.
433 +
434 + 10. Automatic Licensing of Downstream Recipients.
435 +
436 + Each time you convey a covered work, the recipient automatically
437 +receives a license from the original licensors, to run, modify and
438 +propagate that work, subject to this License. You are not responsible
439 +for enforcing compliance by third parties with this License.
440 +
441 + An "entity transaction" is a transaction transferring control of an
442 +organization, or substantially all assets of one, or subdividing an
443 +organization, or merging organizations. If propagation of a covered
444 +work results from an entity transaction, each party to that
445 +transaction who receives a copy of the work also receives whatever
446 +licenses to the work the party's predecessor in interest had or could
447 +give under the previous paragraph, plus a right to possession of the
448 +Corresponding Source of the work from the predecessor in interest, if
449 +the predecessor has it or can get it with reasonable efforts.
450 +
451 + You may not impose any further restrictions on the exercise of the
452 +rights granted or affirmed under this License. For example, you may
453 +not impose a license fee, royalty, or other charge for exercise of
454 +rights granted under this License, and you may not initiate litigation
455 +(including a cross-claim or counterclaim in a lawsuit) alleging that
456 +any patent claim is infringed by making, using, selling, offering for
457 +sale, or importing the Program or any portion of it.
458 +
459 + 11. Patents.
460 +
461 + A "contributor" is a copyright holder who authorizes use under this
462 +License of the Program or a work on which the Program is based. The
463 +work thus licensed is called the contributor's "contributor version".
464 +
465 + A contributor's "essential patent claims" are all patent claims
466 +owned or controlled by the contributor, whether already acquired or
467 +hereafter acquired, that would be infringed by some manner, permitted
468 +by this License, of making, using, or selling its contributor version,
469 +but do not include claims that would be infringed only as a
470 +consequence of further modification of the contributor version. For
471 +purposes of this definition, "control" includes the right to grant
472 +patent sublicenses in a manner consistent with the requirements of
473 +this License.
474 +
475 + Each contributor grants you a non-exclusive, worldwide, royalty-free
476 +patent license under the contributor's essential patent claims, to
477 +make, use, sell, offer for sale, import and otherwise run, modify and
478 +propagate the contents of its contributor version.
479 +
480 + In the following three paragraphs, a "patent license" is any express
481 +agreement or commitment, however denominated, not to enforce a patent
482 +(such as an express permission to practice a patent or covenant not to
483 +sue for patent infringement). To "grant" such a patent license to a
484 +party means to make such an agreement or commitment not to enforce a
485 +patent against the party.
486 +
487 + If you convey a covered work, knowingly relying on a patent license,
488 +and the Corresponding Source of the work is not available for anyone
489 +to copy, free of charge and under the terms of this License, through a
490 +publicly available network server or other readily accessible means,
491 +then you must either (1) cause the Corresponding Source to be so
492 +available, or (2) arrange to deprive yourself of the benefit of the
493 +patent license for this particular work, or (3) arrange, in a manner
494 +consistent with the requirements of this License, to extend the patent
495 +license to downstream recipients. "Knowingly relying" means you have
496 +actual knowledge that, but for the patent license, your conveying the
497 +covered work in a country, or your recipient's use of the covered work
498 +in a country, would infringe one or more identifiable patents in that
499 +country that you have reason to believe are valid.
500 +
501 + If, pursuant to or in connection with a single transaction or
502 +arrangement, you convey, or propagate by procuring conveyance of, a
503 +covered work, and grant a patent license to some of the parties
504 +receiving the covered work authorizing them to use, propagate, modify
505 +or convey a specific copy of the covered work, then the patent license
506 +you grant is automatically extended to all recipients of the covered
507 +work and works based on it.
508 +
509 + A patent license is "discriminatory" if it does not include within
510 +the scope of its coverage, prohibits the exercise of, or is
511 +conditioned on the non-exercise of one or more of the rights that are
512 +specifically granted under this License. You may not convey a covered
513 +work if you are a party to an arrangement with a third party that is
514 +in the business of distributing software, under which you make payment
515 +to the third party based on the extent of your activity of conveying
516 +the work, and under which the third party grants, to any of the
517 +parties who would receive the covered work from you, a discriminatory
518 +patent license (a) in connection with copies of the covered work
519 +conveyed by you (or copies made from those copies), or (b) primarily
520 +for and in connection with specific products or compilations that
521 +contain the covered work, unless you entered into that arrangement,
522 +or that patent license was granted, prior to 28 March 2007.
523 +
524 + Nothing in this License shall be construed as excluding or limiting
525 +any implied license or other defenses to infringement that may
526 +otherwise be available to you under applicable patent law.
527 +
528 + 12. No Surrender of Others' Freedom.
529 +
530 + If conditions are imposed on you (whether by court order, agreement or
531 +otherwise) that contradict the conditions of this License, they do not
532 +excuse you from the conditions of this License. If you cannot convey a
533 +covered work so as to satisfy simultaneously your obligations under this
534 +License and any other pertinent obligations, then as a consequence you may
535 +not convey it at all. For example, if you agree to terms that obligate you
536 +to collect a royalty for further conveying from those to whom you convey
537 +the Program, the only way you could satisfy both those terms and this
538 +License would be to refrain entirely from conveying the Program.
539 +
540 + 13. Remote Network Interaction; Use with the GNU General Public License.
541 +
542 + Notwithstanding any other provision of this License, if you modify the
543 +Program, your modified version must prominently offer all users
544 +interacting with it remotely through a computer network (if your version
545 +supports such interaction) an opportunity to receive the Corresponding
546 +Source of your version by providing access to the Corresponding Source
547 +from a network server at no charge, through some standard or customary
548 +means of facilitating copying of software. This Corresponding Source
549 +shall include the Corresponding Source for any work covered by version 3
550 +of the GNU General Public License that is incorporated pursuant to the
551 +following paragraph.
552 +
553 + Notwithstanding any other provision of this License, you have
554 +permission to link or combine any covered work with a work licensed
555 +under version 3 of the GNU General Public License into a single
556 +combined work, and to convey the resulting work. The terms of this
557 +License will continue to apply to the part which is the covered work,
558 +but the work with which it is combined will remain governed by version
559 +3 of the GNU General Public License.
560 +
561 + 14. Revised Versions of this License.
562 +
563 + The Free Software Foundation may publish revised and/or new versions of
564 +the GNU Affero General Public License from time to time. Such new versions
565 +will be similar in spirit to the present version, but may differ in detail to
566 +address new problems or concerns.
567 +
568 + Each version is given a distinguishing version number. If the
569 +Program specifies that a certain numbered version of the GNU Affero General
570 +Public License "or any later version" applies to it, you have the
571 +option of following the terms and conditions either of that numbered
572 +version or of any later version published by the Free Software
573 +Foundation. If the Program does not specify a version number of the
574 +GNU Affero General Public License, you may choose any version ever published
575 +by the Free Software Foundation.
576 +
577 + If the Program specifies that a proxy can decide which future
578 +versions of the GNU Affero General Public License can be used, that proxy's
579 +public statement of acceptance of a version permanently authorizes you
580 +to choose that version for the Program.
581 +
582 + Later license versions may give you additional or different
583 +permissions. However, no additional obligations are imposed on any
584 +author or copyright holder as a result of your choosing to follow a
585 +later version.
586 +
587 + 15. Disclaimer of Warranty.
588 +
589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 +
598 + 16. Limitation of Liability.
599 +
600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 +SUCH DAMAGES.
609 +
610 + 17. Interpretation of Sections 15 and 16.
611 +
612 + If the disclaimer of warranty and limitation of liability provided
613 +above cannot be given local legal effect according to their terms,
614 +reviewing courts shall apply local law that most closely approximates
615 +an absolute waiver of all civil liability in connection with the
616 +Program, unless a warranty or assumption of liability accompanies a
617 +copy of the Program in return for a fee.
618 +
619 + END OF TERMS AND CONDITIONS
620 +
621 + How to Apply These Terms to Your New Programs
622 +
623 + If you develop a new program, and you want it to be of the greatest
624 +possible use to the public, the best way to achieve this is to make it
625 +free software which everyone can redistribute and change under these terms.
626 +
627 + To do so, attach the following notices to the program. It is safest
628 +to attach them to the start of each source file to most effectively
629 +state the exclusion of warranty; and each file should have at least
630 +the "copyright" line and a pointer to where the full notice is found.
631 +
632 + <one line to give the program's name and a brief idea of what it does.>
633 + Copyright (C) <year> <name of author>
634 +
635 + This program is free software: you can redistribute it and/or modify
636 + it under the terms of the GNU Affero General Public License as published by
637 + the Free Software Foundation, either version 3 of the License, or
638 + (at your option) any later version.
639 +
640 + This program is distributed in the hope that it will be useful,
641 + but WITHOUT ANY WARRANTY; without even the implied warranty of
642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 + GNU Affero General Public License for more details.
644 +
645 + You should have received a copy of the GNU Affero General Public License
646 + along with this program. If not, see <https://www.gnu.org/licenses/>.
647 +
648 +Also add information on how to contact you by electronic and paper mail.
649 +
650 + If your software can interact with users remotely through a computer
651 +network, you should also make sure that it provides a way for users to
652 +get its source. For example, if your program is a web application, its
653 +interface could display a "Source" link that leads users to an archive
654 +of the code. There are many ways you could offer source, and different
655 +solutions will be better for different programs; see section 13 for the
656 +specific requirements.
657 +
658 + You should also get your employer (if you work as a programmer) or school,
659 +if any, to sign a "copyright disclaimer" for the program, if necessary.
660 +For more information on this, and how to apply and follow the GNU AGPL, see
661 +<https://www.gnu.org/licenses/>.
A README.md
+151 -0
@@ -0,0 +1,151 @@
1 +# PicLead
2 +
3 +Application Android **hors-ligne** : scan d’une carte de visite → OCR local (Tesseract) → brouillon éditable → **carnet local** (SQLite), contact Android et export `.vcf`.
4 +
5 +Aucune dépendance Google Play Services / ML Kit, aucun LLM. **OCR et carnet restent 100 % hors-ligne** ; la permission **Internet** sert uniquement à la **sync optionnelle** avec un serveur [PicLead](https://github.com/) (voir ci-dessous).
6 +
7 +[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
8 +![Plateforme](https://img.shields.io/badge/Plateforme-Android%2012%2B-3DDC84?logo=android&logoColor=white)
9 +![100% on-device](https://img.shields.io/badge/100%25-on--device-success)
10 +
11 +## Mini-CRM (carnet local)
12 +
13 +L’écran d’accueil est un **carnet offline** stocké en SQLite (Room) sur l’appareil :
14 +
15 +- **Recherche FTS** sur nom, entreprise, téléphones, e-mails et **notes**
16 +- Tri (nom, prénom, entreprise, date) + abcédaire
17 +- Fiche contact (swipe), notes, édition, photo de profil, **rescan** de la carte (les notes sont conservées)
18 +- **Import VCF** : lecture vCard **3.0 et 4.0**, anti-doublons (skip si déjà présent)
19 +- **Export VCF** : écriture **vCard 3.0** uniquement
20 +- Gestion des **doublons** : badge, revue, fusion ou marquage « contacts différents »
21 +- Création : scan, saisie manuelle ou import
22 +
23 +Sans configuration sync : aucun compte, aucune donnée CRM envoyée hors appareil (export VCF manuel possible).
24 +
25 +## Sync PicLead (optionnelle)
26 +
27 +Sync **offline-first** avec un serveur [PicLead](https://github.com/) (piclead-server) : carnet, projets, tâches kanban et comptes-rendus. **L’OCR et le scan ne nécessitent pas Internet.**
28 +
29 +**Prérequis serveur :** variable `PICLEAD_API_CLES` (clés API par utilisateur), endpoints `/api/auth/cle`, `/api/sync/status`, `/api/sync/pull` — voir le [plan serveur](../Server/docs/plans/sync_api_mobile_v1.md).
30 +
31 +**Configuration dans l’app :** Paramètres (engrenage) → URL **HTTPS** du serveur → identifiant + mot de passe → « Obtenir la clé » → retaper le mot de passe pour déchiffrer la clé API (stockée chiffrée sur l’appareil ; le mot de passe n’est jamais conservé).
32 +
33 +Pour le **développement**, un **réseau interne** ou une **démo** sans TLS : cocher **« Autoriser HTTP (clair) »** avant de saisir une URL `http://…`. Sans cette case, l’app refuse le HTTP (HTTPS obligatoire). Ne pas activer cette option en production sur Internet.
34 +
35 +**Utilisation :** à l’ouverture, un bandeau signale les changements distants sans pull automatique. Le bouton **Sync** pousse les modifications locales (contacts créés/édités dans le carnet **y compris l’image de carte scannée** et la photo de profil si présentes, tâches, CR…) puis tire le delta serveur. Sans sync configurée, le carnet reste local uniquement.
36 +
37 +Plan d’implémentation client : [`docs/plans/sync_card2vcf_projectiaon_v1.md`](docs/plans/sync_card2vcf_projectiaon_v1.md).
38 +Sync images carte/photo : [`docs/plans/sync_cartes_visite_v1.md`](docs/plans/sync_cartes_visite_v1.md).
39 +
40 +> **Upgrade schéma v2 :** la migration Room peut réinitialiser les données locales (documenté dans le plan sync).
41 +
42 +## Agenda / calendriers (optionnel, dépend de la sync)
43 +
44 +PicLead ne contient **aucun écran agenda** : les rendez-vous et réservations de ressources se consultent et se modifient dans l’**app Agenda système** (calendriers **locaux**, hors-GMS — aucun compte Google requis), via des calendriers dédiés que l’app crée et synchronise avec PicLead.
45 +
46 +**Permissions :** `READ_CALENDAR` / `WRITE_CALENDAR`, demandées à l’exécution depuis Paramètres. Sans autorisation, le pont Agenda reste désactivé ; le carnet et la sync CRM (contacts/projets/tâches) continuent de fonctionner normalement.
47 +
48 +**Configuration dans l’app :** Paramètres → section **Calendriers** (visible une fois connecté) :
49 +- Toggle **« Mes RDV »** → crée/lie le calendrier local `PicLead — Mes RDV` ; les événements qui y sont créés/modifiés deviennent des RDV PicLead à la sync.
50 +- Si des identifiants sont configurés, la liste des **ressources** (salles, matériel, véhicules) du serveur s’affiche avec un toggle par ressource active ; les ressources **inactives** sont grisées (non sélectionnables). Activer une ressource crée le calendrier `PicLead — {Salle|Matériel|Véhicule} {nom}` ; un événement qui y est créé devient une **réservation Active** poussée au serveur.
51 +- Désactiver un toggle demande **confirmation** avant de retirer la liaison. Le retrait supprime uniquement la liaison locale (sync arrêtée pour ce calendrier) — l’API `CalendarBridge` n’expose pas de suppression de calendrier Android, le calendrier créé reste donc visible (vide, non synchronisé) dans l’app Agenda système.
52 +
53 +**Sync et conflits :** le bouton **Sync** pousse aussi les événements Agenda liés (RDV + réservations) puis tire le delta serveur (RDV, réservations actives, indisponibilités en lecture). Un bandeau signale un **calendrier local en avance** (événements pas encore poussés). Si une réservation entre en conflit avec le serveur (**HTTP 409**, chevauchement), PicLead conserve l’événement local, retire le blocage serveur pour affichage, puis propose un dialog **« Annuler ma réservation ? »** :
54 +- **Oui** → la réservation locale et son événement Agenda sont supprimés.
55 +- **Non** → rien n’est modifié ; l’arbitrage se fait sur le serveur web (demandes / réclamations restent hors périmètre mobile).
56 +
57 +Spec détaillée : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md). Plan d’implémentation : [`docs/plans/sync_agenda_rdv_ressources_v2.md`](docs/plans/sync_agenda_rdv_ressources_v2.md).
58 +
59 +> **Room v3 (agenda) :** l’ajout des tables RDV/réservations/indisponibilités passe la base Room en version 3 avec `fallbackToDestructiveMigration` — comme pour l’upgrade v2 ci-dessus, la mise à jour **réinitialise les données locales** (carnet inclus) ; ré-exportez en VCF ou synchronisez avant de mettre à jour l’app si nécessaire.
60 +
61 +## Fonctionnement (scan / OCR)
62 +
63 +1. Cadrez la carte et capturez (CameraX).
64 +2. Correction géométrique (OpenCV) + orientation EXIF.
65 +3. OCR Tesseract (7 langues) + post-traitement.
66 +4. Structuration **heuristique** (pas de modèle de langage).
67 +5. Vérifiez le brouillon, puis :
68 + - **Enregistrer** → carnet local (SQLite)
69 + - **Créer le contact** → `ACTION_INSERT` Contacts
70 + - **Exporter VCF** → partage d’un fichier `.vcf` (vCard 3.0)
71 +
72 +## Langues OCR
73 +
74 +| Code | Langue |
75 +|------|--------|
76 +| `fra` | Français |
77 +| `deu` | Allemand |
78 +| `eng` | Anglais |
79 +| `spa` | Espagnol |
80 +| `por` | Portugais |
81 +| `ita` | Italien |
82 +| `pol` | Polonais |
83 +
84 +### Choix fast / full à la compilation
85 +
86 +| Variante | Propriété Gradle | Source | Taille approx. |
87 +|----------|------------------|--------|----------------|
88 +| **fast** (défaut) | `-Ptessdata=fast` | [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) | ~18 Mo |
89 +| **full** | `-Ptessdata=full` | [tessdata](https://github.com/tesseract-ocr/tessdata) | ~80 Mo+ |
90 +
91 +```bash
92 +cd android
93 +./gradlew :app:assembleDebug # fast
94 +./gradlew :app:assembleDebug -Ptessdata=full # full
95 +```
96 +
97 +Premier build d’une variante : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide.
98 +
99 +### Chargement ultérieur (sans rebuild / sans Internet dans l’APK)
100 +
101 +Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
102 +
103 +1. Première exécution (ou changement fast↔full du APK) → copie depuis les assets embarqués.
104 +2. Ensuite → réutilisation des fichiers locaux.
105 +3. Sideload : déposer/remplacer les `*.traineddata` dans ce dossier (ex. `adb push`), puis éventuellement écrire le stamp `external` pour empêcher l’écrasement au prochain changement de variante APK :
106 +
107 +```text
108 +/data/data/fr.ebii.piclead/files/tesseract/tessdata/
109 + fra.traineddata
110 + …
111 + .card2vcf-tessdata-variant # "fast" | "full" | "external"
112 +```
113 +
114 +Pas de téléchargement réseau dans l’app pour les modèles OCR (hors sync optionnelle PicLead).
115 +
116 +APK debug filtré `arm64-v8a`. Pour émulateur x86, retirez temporairement `ndk.abiFilters` dans `app/build.gradle.kts`.
117 +
118 +## Build
119 +
120 +```bash
121 +cd android
122 +./gradlew :app:assembleDebug
123 +./gradlew :app:testDebugUnitTest
124 +```
125 +
126 +APK debug : `android/app/build/outputs/apk/debug/app-debug.apk`
127 +
128 +##Install
129 +adb install -r android/app/build/outputs/apk/debug/app-debug.apk
130 +
131 +
132 +Prérequis : JDK 21, Android SDK (API 35).
133 +
134 +## Limites (assumées)
135 +
136 +- **Pas de LLM** : la qualité du brouillon dépend des heuristiques + du texte OCR. Cartes très décoratives ou manuscrites peuvent échouer.
137 +- **Caméra obligatoire** (`android.hardware.camera` required) pour le scan ; le carnet et l’import VCF restent utilisables sans.
138 +- **Pas de sync obligatoire** : sans configuration PicLead, le carnet est local uniquement ; perte/désinstallation = perte des données sauf export VCF ou sync préalable.
139 +- Structuration sémantique avancée (homonymes, mises en page atypiques) hors périmètre.
140 +
141 +## Design
142 +
143 +UI N&B éditoriale (`design.md`) : coins carrés, hairlines, polices TTF embarquées (Playfair Display, Lora, Manrope — OFL, voir `docs/licenses/OFL-fonts.txt`). Icône : `icon.png`.
144 +
145 +## Origine du code
146 +
147 +Pipeline scan/OCR/contact adapté depuis le projet open source [luciole-mobile](https://github.com/OpenLLM-France/luciole-mobile) (AGPL), sans le module LLM (`cerveau`).
148 +
149 +## Licence
150 +
151 +[AGPL v3](LICENSE)
A android/app/build.gradle.kts
+133 -0
@@ -0,0 +1,133 @@
1 +import java.net.URI
2 +
3 +plugins {
4 + id("com.android.application")
5 + id("org.jetbrains.kotlin.android")
6 + id("org.jetbrains.kotlin.plugin.compose")
7 + id("org.jetbrains.kotlin.plugin.serialization")
8 + id("com.google.devtools.ksp")
9 +}
10 +
11 +/** `fast` (défaut, ~18 Mo) ou `full` (~80 Mo+). Ex. : ./gradlew assembleDebug -Ptessdata=full */
12 +val tessdataVariant: String =
13 + (findProperty("tessdata") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "fast" }
14 +require(tessdataVariant == "fast" || tessdataVariant == "full") {
15 + "Propriété -Ptessdata= invalide (« $tessdataVariant ») — utiliser fast ou full"
16 +}
17 +
18 +val tessdataLanguages = listOf("fra", "deu", "eng", "spa", "por", "ita", "pol")
19 +val tessdataCacheDir = rootProject.layout.projectDirectory.dir("tessdata-cache/$tessdataVariant")
20 +val tessdataGeneratedAssets = layout.buildDirectory.dir("generated/tessdataAssets")
21 +
22 +android {
23 + namespace = "fr.ebii.card2vcf"
24 + compileSdk = 35
25 + defaultConfig {
26 + applicationId = "fr.ebii.piclead"
27 + minSdk = 31
28 + targetSdk = 35
29 + versionCode = 1
30 + versionName = "0.1.0"
31 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 + ndk {
33 + abiFilters += listOf("arm64-v8a")
34 + }
35 + }
36 + buildFeatures {
37 + compose = true
38 + }
39 + compileOptions {
40 + sourceCompatibility = JavaVersion.VERSION_21
41 + targetCompatibility = JavaVersion.VERSION_21
42 + }
43 + kotlinOptions { jvmTarget = "21" }
44 + testOptions {
45 + unitTests.isReturnDefaultValues = true
46 + unitTests.isIncludeAndroidResources = true
47 + }
48 + sourceSets.getByName("main").assets.srcDir(tessdataGeneratedAssets)
49 +}
50 +
51 +val prepareTessdata by tasks.registering {
52 + group = "build"
53 + description = "Télécharge (cache) et prépare les traineddata Tessdata ($tessdataVariant)"
54 + inputs.property("variant", tessdataVariant)
55 + inputs.property("languages", tessdataLanguages.joinToString(","))
56 + outputs.dir(tessdataGeneratedAssets)
57 + doLast {
58 + val cache = tessdataCacheDir.asFile.apply { mkdirs() }
59 + val outDir = tessdataGeneratedAssets.get().asFile
60 + .resolve("tesseract/tessdata")
61 + .apply { mkdirs() }
62 + val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata"
63 + for (lang in tessdataLanguages) {
64 + val cached = cache.resolve("$lang.traineddata")
65 + if (!cached.isFile || cached.length() < 10_000L) {
66 + val url = "https://github.com/tesseract-ocr/$repo/raw/main/$lang.traineddata"
67 + logger.lifecycle("Téléchargement $repo/$lang.traineddata …")
68 + URI(url).toURL().openStream().use { input ->
69 + cached.outputStream().use { output -> input.copyTo(output) }
70 + }
71 + check(cached.isFile && cached.length() >= 10_000L) {
72 + "Échec téléchargement $url"
73 + }
74 + }
75 + cached.copyTo(outDir.resolve("$lang.traineddata"), overwrite = true)
76 + }
77 + outDir.resolve("variant.txt").writeText(tessdataVariant)
78 + outDir.resolve("README.md").writeText(
79 + "Généré au build (variante=$tessdataVariant). Ne pas éditer.\n" +
80 + "Cache : android/tessdata-cache/$tessdataVariant/\n"
81 + )
82 + logger.lifecycle("Tessdata $tessdataVariant prêt (${tessdataLanguages.size} langues).")
83 + }
84 +}
85 +
86 +tasks.matching {
87 + it.name.startsWith("merge") && it.name.endsWith("Assets")
88 +}.configureEach {
89 + dependsOn(prepareTessdata)
90 +}
91 +tasks.named("preBuild").configure { dependsOn(prepareTessdata) }
92 +
93 +dependencies {
94 + implementation(platform("androidx.compose:compose-bom:2024.09.02"))
95 + implementation("androidx.compose.ui:ui")
96 + implementation("androidx.compose.material3:material3")
97 + implementation("androidx.compose.material:material-icons-core")
98 + implementation("androidx.compose.material:material-icons-extended")
99 + implementation("androidx.compose.ui:ui-tooling-preview")
100 + implementation("androidx.activity:activity-compose:1.9.2")
101 + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
102 + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
103 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
104 + implementation("androidx.camera:camera-camera2:1.4.1")
105 + implementation("androidx.camera:camera-lifecycle:1.4.1")
106 + implementation("androidx.camera:camera-view:1.4.1")
107 + implementation("org.opencv:opencv:4.10.0")
108 + implementation("com.github.adaptech-cz.Tesseract4Android:tesseract4android-openmp:4.9.0")
109 + implementation("androidx.exifinterface:exifinterface:1.3.7")
110 +
111 + val room = "2.6.1"
112 + implementation("androidx.room:room-runtime:$room")
113 + implementation("androidx.room:room-ktx:$room")
114 + ksp("androidx.room:room-compiler:$room")
115 + implementation("androidx.navigation:navigation-compose:2.8.2")
116 + testImplementation("androidx.room:room-testing:$room")
117 +
118 + testImplementation("junit:junit:4.13.2")
119 + testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20")
120 + testImplementation("org.robolectric:robolectric:4.13")
121 + testImplementation("androidx.test:core:1.6.1")
122 + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
123 +
124 + implementation("com.squareup.okhttp3:okhttp:4.12.0")
125 + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
126 + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
127 + implementation("androidx.security:security-crypto:1.1.0-alpha06")
128 + implementation("org.bouncycastle:bcprov-jdk18on:1.85")
129 +
130 + androidTestImplementation("androidx.test.ext:junit:1.2.1")
131 + androidTestImplementation("androidx.test:runner:1.6.2")
132 + androidTestImplementation("androidx.test:rules:1.6.1")
133 +}
A android/app/src/androidTest/assets/cards/dimo_genoux.jpg
+0 -0
A android/app/src/androidTest/assets/cards/dimo_genoux_oriented.jpg
+0 -0
A android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/DimoCardOcrInstrumentedTest.kt
+58 -0
@@ -0,0 +1,58 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.BitmapFactory
4 +import androidx.test.ext.junit.runners.AndroidJUnit4
5 +import androidx.test.platform.app.InstrumentationRegistry
6 +import fr.ebii.card2vcf.scan.ImageOrientation
7 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Assert.assertTrue
10 +import org.junit.Test
11 +import org.junit.runner.RunWith
12 +
13 +/**
14 + * Test instrumenté générique : JPEG brut + EXIF → orientation → scan → OCR.
15 + * Couvre le chemin réel utilisateur (photo téléphone avec EXIF Orientation=6).
16 + */
17 +@RunWith(AndroidJUnit4::class)
18 +class DimoCardOcrInstrumentedTest {
19 +
20 + @Test
21 + fun scanEtOcrAvecCorrectionExif() {
22 + val context = InstrumentationRegistry.getInstrumentation().targetContext
23 + OpenCvScanEngine.ensureOpenCv()
24 + val assets = InstrumentationRegistry.getInstrumentation().context.assets
25 +
26 + // JPEG brut (avec EXIF 6), pas la version déjà orientée.
27 + val jpeg = assets.open("cards/dimo_genoux.jpg").use { it.readBytes() }
28 + val oriented = ImageOrientation.fromJpegBytes(jpeg)!!
29 + assertTrue("EXIF doit corriger la photo dimo", oriented.fromExif)
30 + assertEquals(90, oriented.appliedRotationDegrees)
31 +
32 + val scanned = OpenCvScanEngine().scan(oriented.bitmap)
33 + val ocr = TesseractOcrEngine(context).recognize(scanned.bitmap)
34 +
35 + println("=== OCR RAW ===\n${ocr.rawText}")
36 + println("=== phones=${ocr.phones}")
37 + println("=== emails=${ocr.emails}")
38 + println("=== urls=${ocr.urls}")
39 +
40 + assertTrue("texte OCR trop court: ${ocr.rawText.length}", ocr.rawText.length > 20)
41 + assertTrue(
42 + "email attendu absent: ${ocr.emails}",
43 + ocr.emails.any { it.contains("dimosoftware", ignoreCase = true) }
44 + || ocr.rawText.contains("dimosoftware", ignoreCase = true),
45 + )
46 + }
47 +
48 + @Test
49 + fun fallbackCameraXSiExifNormal() {
50 + val assets = InstrumentationRegistry.getInstrumentation().context.assets
51 + // Version déjà orientée : EXIF normal → pas de rotation EXIF
52 + val jpeg = assets.open("cards/dimo_genoux_oriented.jpg").use { it.readBytes() }
53 + val withCam = ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees = 0)!!
54 + assertEquals(0, withCam.appliedRotationDegrees)
55 + val bmp = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size)!!
56 + assertEquals(bmp.width, withCam.bitmap.width)
57 + }
58 +}
A android/app/src/main/AndroidManifest.xml
+35 -0
@@ -0,0 +1,35 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 + <uses-permission android:name="android.permission.CAMERA" />
4 + <uses-permission android:name="android.permission.INTERNET" />
5 + <uses-permission android:name="android.permission.READ_CALENDAR" />
6 + <uses-permission android:name="android.permission.WRITE_CALENDAR" />
7 + <uses-feature android:name="android.hardware.camera" android:required="true" />
8 +
9 + <application
10 + android:name=".Card2vcfApp"
11 + android:label="@string/app_name"
12 + android:icon="@mipmap/ic_launcher"
13 + android:roundIcon="@mipmap/ic_launcher_round"
14 + android:networkSecurityConfig="@xml/network_security_config"
15 + android:theme="@android:style/Theme.Material.Light.NoActionBar">
16 + <provider
17 + android:name="androidx.core.content.FileProvider"
18 + android:authorities="${applicationId}.fileprovider"
19 + android:exported="false"
20 + android:grantUriPermissions="true">
21 + <meta-data
22 + android:name="android.support.FILE_PROVIDER_PATHS"
23 + android:resource="@xml/file_paths" />
24 + </provider>
25 + <activity
26 + android:name=".MainActivity"
27 + android:exported="true"
28 + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density">
29 + <intent-filter>
30 + <action android:name="android.intent.action.MAIN" />
31 + <category android:name="android.intent.category.LAUNCHER" />
32 + </intent-filter>
33 + </activity>
34 + </application>
35 +</manifest>
A android/app/src/main/assets/tesseract/tessdata/.gitkeep
+0 -0
A android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt
+8 -0
@@ -0,0 +1,8 @@
1 +package fr.ebii.card2vcf
2 +
3 +import android.app.Application
4 +import fr.ebii.card2vcf.data.CrmDatabase
5 +
6 +class Card2vcfApp : Application() {
7 + val database: CrmDatabase by lazy { CrmDatabase.get(this) }
8 +}
A android/app/src/main/java/fr/ebii/card2vcf/MainActivity.kt
+65 -0
@@ -0,0 +1,65 @@
1 +package fr.ebii.card2vcf
2 +
3 +import android.os.Bundle
4 +import androidx.activity.ComponentActivity
5 +import androidx.activity.compose.setContent
6 +import androidx.activity.enableEdgeToEdge
7 +import androidx.compose.foundation.background
8 +import androidx.compose.foundation.layout.Box
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.systemBarsPadding
11 +import androidx.compose.ui.Modifier
12 +import fr.ebii.card2vcf.contact.ContactInsertIntent
13 +import fr.ebii.card2vcf.contact.VcfShare
14 +import fr.ebii.card2vcf.data.ContactImageStore
15 +import fr.ebii.card2vcf.data.ContactRepository
16 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
17 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
18 +import fr.ebii.card2vcf.ui.nav.Card2vcfNavHost
19 +import fr.ebii.card2vcf.ui.theme.Card2vcfTheme
20 +import fr.ebii.card2vcf.ui.theme.Canvas
21 +
22 +class MainActivity : ComponentActivity() {
23 + override fun onCreate(savedInstanceState: Bundle?) {
24 + super.onCreate(savedInstanceState)
25 + enableEdgeToEdge()
26 + OpenCvScanEngine.ensureOpenCv()
27 +
28 + val app = application as Card2vcfApp
29 + val images = ContactImageStore(this)
30 + val credentialsStore = SyncCredentialsStore(this)
31 + val repository = ContactRepository(
32 + dao = app.database.crmContactDao(),
33 + images = images,
34 + syncOpDao = app.database.syncOpDao(),
35 + isSyncEnabled = { credentialsStore.isConfigured() },
36 + )
37 +
38 + setContent {
39 + Card2vcfTheme {
40 + Box(
41 + Modifier
42 + .fillMaxSize()
43 + .background(Canvas)
44 + .systemBarsPadding(),
45 + ) {
46 + Card2vcfNavHost(
47 + repository = repository,
48 + database = app.database,
49 + onCreateContact = { card ->
50 + startActivity(ContactInsertIntent.build(card))
51 + },
52 + onExportVcf = { card ->
53 + startActivity(
54 + android.content.Intent.createChooser(
55 + VcfShare.shareIntent(this@MainActivity, card),
56 + getString(R.string.scan_exporter_vcf),
57 + ),
58 + )
59 + },
60 + )
61 + }
62 + }
63 + }
64 + }
65 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactCard.kt
+29 -0
@@ -0,0 +1,29 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +data class ContactCard(
4 + val fullName: String? = null,
5 + val firstName: String? = null,
6 + val lastName: String? = null,
7 + val company: String? = null,
8 + val jobTitle: String? = null,
9 + val phones: List<String> = emptyList(),
10 + val emails: List<String> = emptyList(),
11 + val website: String? = null,
12 + val address: String? = null,
13 + val note: String? = null,
14 +) {
15 + fun displayName(): String =
16 + fullName?.takeIf { it.isNotBlank() }
17 + ?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
18 + ?: company.orEmpty()
19 +
20 + fun hasAnyField(): Boolean =
21 + displayName().isNotBlank()
22 + || company?.isNotBlank() == true
23 + || jobTitle?.isNotBlank() == true
24 + || phones.isNotEmpty()
25 + || emails.isNotEmpty()
26 + || website?.isNotBlank() == true
27 + || address?.isNotBlank() == true
28 + || note?.isNotBlank() == true
29 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactDraftMerge.kt
+40 -0
@@ -0,0 +1,40 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +
5 +/**
6 + * Fusionne les sources, par priorité de fiabilité :
7 + * 1. Regex OCR (tél / email / url) — toujours prioritaire
8 + * 2. Carte LLM optionnelle (`llm`) si présente et non vide — Card2vcf passe `null`
9 + * 3. Heuristiques déterministes (nom / société / poste / adresse)
10 + */
11 +object ContactDraftMerge {
12 + fun merge(llm: ContactCard?, ocr: OcrResult): ContactCard {
13 + val heuristic = ContactHeuristicParser.parse(ocr)
14 + val base = llm ?: ContactCard()
15 + return ContactCard(
16 + fullName = prefer(base.fullName, heuristic.fullName),
17 + firstName = prefer(base.firstName, heuristic.firstName),
18 + lastName = prefer(base.lastName, heuristic.lastName),
19 + company = prefer(base.company, heuristic.company),
20 + jobTitle = prefer(base.jobTitle, heuristic.jobTitle),
21 + phones = mergeStrings(ocr.phones, base.phones + heuristic.phones),
22 + emails = mergeStrings(ocr.emails, base.emails + heuristic.emails),
23 + website = prefer(ocr.urls.firstOrNull(), prefer(base.website, heuristic.website)),
24 + address = prefer(base.address, heuristic.address),
25 + note = prefer(base.note, heuristic.note),
26 + )
27 + }
28 +
29 + private fun prefer(primary: String?, fallback: String?): String? =
30 + primary?.takeIf { it.isNotBlank() } ?: fallback?.takeIf { it.isNotBlank() }
31 +
32 + private fun mergeStrings(preferred: List<String>, extra: List<String>): List<String> {
33 + val seen = linkedSetOf<String>()
34 + (preferred + extra).forEach { v ->
35 + val n = v.trim()
36 + if (n.isNotEmpty()) seen.add(n)
37 + }
38 + return seen.toList()
39 + }
40 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactHeuristicParser.kt
+178 -0
@@ -0,0 +1,178 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +
5 +/**
6 + * Structuration déterministe d'un texte OCR de carte de visite.
7 + * Source unique de structuration dans Card2vcf (pas de LLM).
8 + */
9 +object ContactHeuristicParser {
10 +
11 + private val jobKeywords = Regex(
12 + """(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|""" +
13 + """manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|""" +
14 + """associé|associe|fondateur|founder|head of|chef de|avocat|docteur|dr\.?)\b""",
15 + )
16 + private val addressKeywords = Regex(
17 + """(?i)\b(rue|avenue|av\.?|bd\.?|boulevard|all[ée]e|chemin|place|impasse|""" +
18 + """cedex|cs\b|bp\b|boîte postale|france|siège|siege)\b|\b\d{5}\b""",
19 + )
20 + private val companyKeywords = Regex(
21 + """(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|""" +
22 + """group|groupe|technologies|tech|consulting)\b""",
23 + )
24 + private val skipLine = Regex(
25 + """(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|""" +
26 + """siège social|siege social|linkedin|twitter)\b""",
27 + )
28 + private val emailOrUrlOrPhone = Regex(
29 + """@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d""",
30 + RegexOption.IGNORE_CASE,
31 + )
32 +
33 + fun parse(ocr: OcrResult): ContactCard {
34 + val lines = ocr.lines.ifEmpty { ocr.rawText.lines().map { it.trim() }.filter { it.isNotEmpty() } }
35 + val residual = lines.toMutableList()
36 +
37 + fun takeMatching(pred: (String) -> Boolean): String? {
38 + val idx = residual.indexOfFirst(pred)
39 + if (idx < 0) return null
40 + return residual.removeAt(idx)
41 + }
42 +
43 + val jobTitle = takeMatching { jobKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
44 + val address = residual
45 + .filter { addressKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
46 + .maxByOrNull { it.length }
47 + ?.also { residual.remove(it) }
48 +
49 + val personLine = residual
50 + .asSequence()
51 + .filter { looksLikePersonName(it) }
52 + .maxByOrNull { personScore(it) }
53 + ?.also { residual.remove(it) }
54 +
55 + val (firstName, lastName, fullName) = splitPersonName(personLine)
56 +
57 + val companyFromEmail = ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) }
58 + val companyLine = residual
59 + .asSequence()
60 + .filter { looksLikeCompany(it) }
61 + .maxByOrNull { companyScore(it) }
62 + ?.also { residual.remove(it) }
63 + val company = cleanCompany(companyLine) ?: companyFromEmail
64 +
65 + val website = ocr.urls.firstOrNull()
66 + val noteParts = residual.filter { line ->
67 + !skipLine.containsMatchIn(line)
68 + && !emailOrUrlOrPhone.containsMatchIn(line)
69 + && line.length > 2
70 + }
71 +
72 + return ContactCard(
73 + fullName = fullName,
74 + firstName = firstName,
75 + lastName = lastName,
76 + company = company,
77 + jobTitle = cleanJob(jobTitle),
78 + phones = ocr.phones,
79 + emails = ocr.emails,
80 + website = website,
81 + address = cleanAddress(address),
82 + note = noteParts.take(3).joinToString(" · ").ifBlank { null },
83 + )
84 + }
85 +
86 + internal fun looksLikePersonName(line: String): Boolean {
87 + val t = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim()
88 + if (t.length !in 4..60) return false
89 + if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false
90 + if (addressKeywords.containsMatchIn(t)) return false
91 + if (emailOrUrlOrPhone.containsMatchIn(line)) return false
92 + val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() }
93 + if (parts.size !in 2..4) return false
94 + // Au moins un token capitalisé / majuscules
95 + val caps = parts.count { it.first().isUpperCase() }
96 + return caps >= 2
97 + }
98 +
99 + internal fun personScore(line: String): Int {
100 + val parts = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim().split(Regex("""\s+"""))
101 + var s = parts.size * 10
102 + if (parts.any { it.length >= 2 && it == it.uppercase() && it.any { c -> c.isLetter() } }) s += 40
103 + if (parts.size in 2..3) s += 15
104 + return s
105 + }
106 +
107 + internal fun splitPersonName(line: String?): Triple<String?, String?, String?> {
108 + if (line.isNullOrBlank()) return Triple(null, null, null)
109 + val cleaned = line
110 + .replace('!', 'l') // Pau! → Paul (erreur OCR fréquente)
111 + .replace(Regex("""[^\p{L}\s\-']"""), " ")
112 + .replace(Regex("""\s+"""), " ")
113 + .trim()
114 + val parts = cleaned.split(' ').filter { it.isNotBlank() }
115 + if (parts.isEmpty()) return Triple(null, null, null)
116 + val lastCaps = parts.indexOfLast { it.length >= 2 && it == it.uppercase() && it.all { c -> !c.isLetter() || c.isUpperCase() } }
117 + fun titleWord(w: String) = w.lowercase().replaceFirstChar { it.titlecase() }
118 + return if (lastCaps > 0) {
119 + val last = titleWord(parts[lastCaps])
120 + val first = parts.take(lastCaps).joinToString(" ") { titleWord(it) }
121 + // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes
122 + val firstKeep = parts.take(lastCaps).joinToString(" ")
123 + Triple(firstKeep, last, "$firstKeep $last")
124 + } else if (parts.size >= 2) {
125 + val last = titleWord(parts.last())
126 + val first = parts.dropLast(1).joinToString(" ")
127 + Triple(first, last, "$first $last")
128 + } else {
129 + Triple(null, null, cleaned)
130 + }
131 + }
132 +
133 + private fun looksLikeCompany(line: String): Boolean {
134 + if (line.length !in 2..50) return false
135 + if (emailOrUrlOrPhone.containsMatchIn(line)) return false
136 + if (jobKeywords.containsMatchIn(line)) return false
137 + if (addressKeywords.containsMatchIn(line)) return false
138 + if (looksLikePersonName(line)) return false
139 + return companyKeywords.containsMatchIn(line)
140 + || line == line.uppercase()
141 + || line.split(Regex("""\s+""")).size <= 3
142 + }
143 +
144 + private fun companyScore(line: String): Int {
145 + var s = 0
146 + if (companyKeywords.containsMatchIn(line)) s += 50
147 + if (line == line.uppercase()) s += 20
148 + if (line.contains("software", ignoreCase = true)) s += 30
149 + return s + (40 - line.length).coerceAtLeast(0)
150 + }
151 +
152 + private fun cleanCompany(line: String?): String? {
153 + if (line.isNullOrBlank()) return null
154 + return line
155 + .replace(Regex("""[&—–\-_|]+"""), " ")
156 + .replace(Regex("""\s+"""), " ")
157 + .trim()
158 + .ifBlank { null }
159 + }
160 +
161 + private fun cleanJob(line: String?): String? =
162 + line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }
163 +
164 + private fun cleanAddress(line: String?): String? =
165 + line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }
166 +
167 + internal fun companyFromEmailDomain(email: String): String? {
168 + val domain = email.substringAfter('@', "").substringBefore('.')
169 + if (domain.length < 3) return null
170 + // dimosoftware → Dimo Software (best-effort)
171 + val spaced = domain
172 + .replace(Regex("""(?<=[a-z])(?=[A-Z])"""), " ")
173 + .replace("software", " Software", ignoreCase = true)
174 + .replace("solutions", " Solutions", ignoreCase = true)
175 + .trim()
176 + return spaced.replaceFirstChar { it.titlecase() }.ifBlank { null }
177 + }
178 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactInsertIntent.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Intent
4 +import android.provider.ContactsContract
5 +
6 +object ContactInsertIntent {
7 + fun build(card: ContactCard): Intent {
8 + val intent = Intent(Intent.ACTION_INSERT).apply {
9 + type = ContactsContract.Contacts.CONTENT_TYPE
10 + }
11 + card.fullName?.takeIf { it.isNotBlank() }?.let {
12 + intent.putExtra(ContactsContract.Intents.Insert.NAME, it)
13 + }
14 + card.company?.takeIf { it.isNotBlank() }?.let {
15 + intent.putExtra(ContactsContract.Intents.Insert.COMPANY, it)
16 + }
17 + card.jobTitle?.takeIf { it.isNotBlank() }?.let {
18 + intent.putExtra(ContactsContract.Intents.Insert.JOB_TITLE, it)
19 + }
20 + card.phones.firstOrNull()?.let {
21 + intent.putExtra(ContactsContract.Intents.Insert.PHONE, it)
22 + }
23 + card.emails.firstOrNull()?.let {
24 + intent.putExtra(ContactsContract.Intents.Insert.EMAIL, it)
25 + }
26 + card.address?.takeIf { it.isNotBlank() }?.let {
27 + intent.putExtra(ContactsContract.Intents.Insert.POSTAL, it)
28 + }
29 + card.note?.takeIf { it.isNotBlank() }?.let {
30 + intent.putExtra(ContactsContract.Intents.Insert.NOTES, it)
31 + }
32 + return intent
33 + }
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt
+135 -0
@@ -0,0 +1,135 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +object VCardParser {
4 + fun parse(text: String): List<ContactCard> {
5 + val unfolded = unfold(text)
6 + val cards = mutableListOf<ContactCard>()
7 + var buf = mutableListOf<String>()
8 + for (line in unfolded.lineSequence()) {
9 + val t = line.trimEnd()
10 + when {
11 + t.equals("BEGIN:VCARD", true) -> buf = mutableListOf()
12 + t.equals("END:VCARD", true) -> cards.add(parseOne(buf))
13 + else -> if (t.isNotEmpty()) buf.add(t)
14 + }
15 + }
16 + return cards
17 + }
18 +
19 + private fun unfold(text: String): String = buildString {
20 + for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) {
21 + if (line.startsWith(" ") || line.startsWith("\t")) {
22 + append(line.drop(1))
23 + } else {
24 + if (isNotEmpty()) append('\n')
25 + append(line)
26 + }
27 + }
28 + }
29 +
30 + private fun parseOne(lines: List<String>): ContactCard {
31 + var fullName: String? = null
32 + var firstName: String? = null
33 + var lastName: String? = null
34 + var company: String? = null
35 + var jobTitle: String? = null
36 + val phones = mutableListOf<String>()
37 + val emails = mutableListOf<String>()
38 + var website: String? = null
39 + var address: String? = null
40 + var note: String? = null
41 +
42 + for (line in lines) {
43 + val colon = line.indexOf(':')
44 + if (colon < 0) continue
45 + val nameAndParams = line.substring(0, colon)
46 + val rawValue = line.substring(colon + 1)
47 + val prop = propertyName(nameAndParams)
48 + if (prop.equals("PHOTO", true) || prop.equals("VERSION", true) || prop.equals("BEGIN", true)) {
49 + continue
50 + }
51 + val value = unescape(rawValue)
52 + when {
53 + prop.equals("FN", true) -> fullName = value.takeIf { it.isNotBlank() }
54 + prop.equals("N", true) -> {
55 + val parts = splitUnescaped(rawValue)
56 + lastName = unescape(parts.getOrElse(0) { "" }).takeIf { it.isNotBlank() }
57 + firstName = unescape(parts.getOrElse(1) { "" }).takeIf { it.isNotBlank() }
58 + }
59 + prop.equals("ORG", true) -> company = value.takeIf { it.isNotBlank() }
60 + prop.equals("TITLE", true) -> jobTitle = value.takeIf { it.isNotBlank() }
61 + prop.equals("TEL", true) -> value.takeIf { it.isNotBlank() }?.let { phones.add(it) }
62 + prop.equals("EMAIL", true) -> value.takeIf { it.isNotBlank() }?.let { emails.add(it) }
63 + prop.equals("URL", true) -> website = value.takeIf { it.isNotBlank() }
64 + prop.equals("ADR", true) -> {
65 + val parts = splitUnescaped(rawValue)
66 + .map { unescape(it).trim() }
67 + .filter { it.isNotEmpty() }
68 + address = parts.joinToString(", ").takeIf { it.isNotBlank() }
69 + }
70 + prop.equals("NOTE", true) -> note = value.takeIf { it.isNotBlank() }
71 + }
72 + }
73 +
74 + return ContactCard(
75 + fullName = fullName,
76 + firstName = firstName,
77 + lastName = lastName,
78 + company = company,
79 + jobTitle = jobTitle,
80 + phones = phones,
81 + emails = emails,
82 + website = website,
83 + address = address,
84 + note = note,
85 + )
86 + }
87 +
88 + /** Property name before ':' : strip group prefix (item1.), take part before first ';'. */
89 + private fun propertyName(nameAndParams: String): String {
90 + val afterGroup = nameAndParams.substringAfterLast('.', nameAndParams)
91 + return afterGroup.substringBefore(';')
92 + }
93 +
94 + private fun unescape(value: String): String = buildString(value.length) {
95 + var i = 0
96 + while (i < value.length) {
97 + val c = value[i]
98 + if (c == '\\' && i + 1 < value.length) {
99 + when (val n = value[i + 1]) {
100 + 'n', 'N' -> append('\n')
101 + 'r', 'R' -> { /* skip CR */ }
102 + ';', ',', '\\' -> append(n)
103 + else -> append(n)
104 + }
105 + i += 2
106 + } else {
107 + append(c)
108 + i++
109 + }
110 + }
111 + }
112 +
113 + /** Split on unescaped ';' (vCard structured values). */
114 + private fun splitUnescaped(value: String): List<String> {
115 + val parts = mutableListOf<String>()
116 + val buf = StringBuilder()
117 + var i = 0
118 + while (i < value.length) {
119 + val c = value[i]
120 + if (c == '\\' && i + 1 < value.length) {
121 + buf.append(c).append(value[i + 1])
122 + i += 2
123 + } else if (c == ';') {
124 + parts.add(buf.toString())
125 + buf.clear()
126 + i++
127 + } else {
128 + buf.append(c)
129 + i++
130 + }
131 + }
132 + parts.add(buf.toString())
133 + return parts
134 + }
135 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/VCardSerializer.kt
+32 -0
@@ -0,0 +1,32 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +object VCardSerializer {
4 + fun toVCard3(card: ContactCard): String = buildString {
5 + appendLine("BEGIN:VCARD")
6 + appendLine("VERSION:3.0")
7 + card.fullName?.takeIf { it.isNotBlank() }?.let { appendLine("FN:${escape(it)}") }
8 + val n = buildNField(card)
9 + if (n != null) appendLine("N:$n")
10 + card.company?.takeIf { it.isNotBlank() }?.let { appendLine("ORG:${escape(it)}") }
11 + card.jobTitle?.takeIf { it.isNotBlank() }?.let { appendLine("TITLE:${escape(it)}") }
12 + card.phones.forEach { appendLine("TEL;TYPE=CELL:${escape(it)}") }
13 + card.emails.forEach { appendLine("EMAIL;TYPE=INTERNET:${escape(it)}") }
14 + card.website?.takeIf { it.isNotBlank() }?.let { appendLine("URL:${escape(it)}") }
15 + card.address?.takeIf { it.isNotBlank() }?.let { appendLine("ADR;TYPE=WORK:;;${escape(it)};;;;") }
16 + card.note?.takeIf { it.isNotBlank() }?.let { appendLine("NOTE:${escape(it)}") }
17 + appendLine("END:VCARD")
18 + }.trimEnd()
19 +
20 + private fun buildNField(card: ContactCard): String? {
21 + val last = card.lastName?.takeIf { it.isNotBlank() } ?: return null
22 + val first = card.firstName.orEmpty()
23 + return "${escape(last)};${escape(first)};;;"
24 + }
25 +
26 + internal fun escape(value: String): String =
27 + value.replace("\\", "\\\\")
28 + .replace(";", "\\;")
29 + .replace(",", "\\,")
30 + .replace("\n", "\\n")
31 + .replace("\r", "")
32 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/VcfShare.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Context
4 +import android.content.Intent
5 +import androidx.core.content.FileProvider
6 +import java.io.File
7 +
8 +object VcfShare {
9 + fun fileName(card: ContactCard): String {
10 + val base = card.displayName().ifBlank { "contact" }
11 + .lowercase()
12 + .replace(Regex("[^a-z0-9]+"), "-")
13 + .trim('-')
14 + .ifBlank { "contact" }
15 + return "$base.vcf"
16 + }
17 +
18 + fun writeTempFile(context: Context, card: ContactCard): File {
19 + val dir = File(context.cacheDir, "vcf").apply { mkdirs() }
20 + val file = File(dir, fileName(card))
21 + file.writeText(VCardSerializer.toVCard3(card))
22 + return file
23 + }
24 +
25 + fun shareIntent(context: Context, card: ContactCard): Intent {
26 + val file = writeTempFile(context, card)
27 + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
28 + return Intent(Intent.ACTION_SEND).apply {
29 + type = "text/vcard"
30 + putExtra(Intent.EXTRA_STREAM, uri)
31 + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
32 + }
33 + }
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/crm/AlphabetIndex.kt
+45 -0
@@ -0,0 +1,45 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +
5 +object AlphabetIndex {
6 + fun sortKey(c: CrmContactEntity, sort: ContactSort): String = when (sort) {
7 + ContactSort.LAST_NAME -> c.lastName ?: c.fullName ?: ""
8 + ContactSort.FIRST_NAME -> c.firstName ?: c.fullName ?: ""
9 + ContactSort.COMPANY -> c.company ?: ""
10 + ContactSort.CREATED_AT -> c.createdAt.toString().padStart(20, '0')
11 + }
12 +
13 + fun sectionLetter(key: String): Char {
14 + val c = key.trim().firstOrNull()?.uppercaseChar()
15 + return if (c != null && c in 'A'..'Z') c else '#'
16 + }
17 +
18 + /**
19 + * Alphabetical sorts: ordered A–Z then `#`, contacts sorted within each section.
20 + * [ContactSort.CREATED_AT]: single flat section (chrono desc), no A–Z rail sections.
21 + */
22 + fun group(
23 + contacts: List<CrmContactEntity>,
24 + sort: ContactSort,
25 + ): LinkedHashMap<Char, List<CrmContactEntity>> {
26 + if (sort == ContactSort.CREATED_AT) {
27 + val sorted = contacts.sortedByDescending { it.createdAt }
28 + return linkedMapOf('#' to sorted)
29 + }
30 + val sorted = contacts.sortedWith(
31 + compareBy(String.CASE_INSENSITIVE_ORDER) { sortKey(it, sort) },
32 + )
33 + val buckets = LinkedHashMap<Char, MutableList<CrmContactEntity>>()
34 + for (c in sorted) {
35 + val letter = sectionLetter(sortKey(c, sort))
36 + buckets.getOrPut(letter) { mutableListOf() }.add(c)
37 + }
38 + val out = LinkedHashMap<Char, List<CrmContactEntity>>()
39 + for ((k, v) in buckets) {
40 + if (k != '#') out[k] = v
41 + }
42 + buckets['#']?.let { out['#'] = it }
43 + return out
44 + }
45 +}
A android/app/src/main/java/fr/ebii/card2vcf/crm/ContactMerge.kt
+55 -0
@@ -0,0 +1,55 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +
5 +data class MergeFieldChoices(
6 + val fullNameFromId: Long,
7 + val firstNameFromId: Long,
8 + val lastNameFromId: Long,
9 + val companyFromId: Long,
10 + val jobTitleFromId: Long,
11 + val websiteFromId: Long,
12 + val addressFromId: Long,
13 + val notesMode: NotesMode,
14 + val cardImageFromId: Long?,
15 + val profileImageFromId: Long?,
16 + val keepId: Long,
17 +)
18 +
19 +enum class NotesMode { KEEP_PRIMARY, CONCAT }
20 +
21 +object ContactMerge {
22 + fun merge(
23 + sources: Map<Long, CrmContactEntity>,
24 + choices: MergeFieldChoices,
25 + now: Long = System.currentTimeMillis(),
26 + ): CrmContactEntity {
27 + fun scalar(picker: Long, sel: (CrmContactEntity) -> String?) =
28 + sources.getValue(picker).let(sel)
29 + val keep = sources.getValue(choices.keepId)
30 + val phones = sources.values.flatMap { it.phones }.distinct()
31 + val emails = sources.values.flatMap { it.emails }.distinct()
32 + val notes = when (choices.notesMode) {
33 + NotesMode.KEEP_PRIMARY -> keep.notes
34 + NotesMode.CONCAT -> sources.values.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }
35 + .distinct()
36 + .joinToString("\n---\n")
37 + .ifBlank { null }
38 + }
39 + return keep.copy(
40 + fullName = scalar(choices.fullNameFromId) { it.fullName },
41 + firstName = scalar(choices.firstNameFromId) { it.firstName },
42 + lastName = scalar(choices.lastNameFromId) { it.lastName },
43 + company = scalar(choices.companyFromId) { it.company },
44 + jobTitle = scalar(choices.jobTitleFromId) { it.jobTitle },
45 + website = scalar(choices.websiteFromId) { it.website },
46 + address = scalar(choices.addressFromId) { it.address },
47 + notes = notes,
48 + phones = phones,
49 + emails = emails,
50 + phonesText = phones.joinToString(" "),
51 + emailsText = emails.joinToString(" "),
52 + updatedAt = now,
53 + )
54 + }
55 +}
A android/app/src/main/java/fr/ebii/card2vcf/crm/ContactNormalizer.kt
+6 -0
@@ -0,0 +1,6 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +object ContactNormalizer {
4 + fun email(raw: String): String = raw.trim().lowercase()
5 + fun phone(raw: String): String = raw.filter { it.isDigit() }
6 +}
A android/app/src/main/java/fr/ebii/card2vcf/crm/ContactSort.kt
+3 -0
@@ -0,0 +1,3 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +enum class ContactSort { LAST_NAME, FIRST_NAME, COMPANY, CREATED_AT }
A android/app/src/main/java/fr/ebii/card2vcf/crm/DuplicateDetector.kt
+58 -0
@@ -0,0 +1,58 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.data.DuplicateExclusionEntity
5 +
6 +object DuplicateDetector {
7 + fun clusters(
8 + contacts: List<CrmContactEntity>,
9 + exclusions: List<DuplicateExclusionEntity>,
10 + ): Map<Long, Set<Long>> {
11 + val parent = contacts.associate { it.id to it.id }.toMutableMap()
12 + fun find(x: Long): Long {
13 + var p = x
14 + while (parent[p] != p) p = parent[p]!!
15 + return p
16 + }
17 + fun union(a: Long, b: Long) {
18 + val ra = find(a)
19 + val rb = find(b)
20 + if (ra != rb) parent[ra] = rb
21 + }
22 + val excluded = exclusions.map { it.idA to it.idB }.toSet()
23 + fun excludedPair(a: Long, b: Long): Boolean {
24 + val lo = minOf(a, b)
25 + val hi = maxOf(a, b)
26 + return (lo to hi) in excluded
27 + }
28 + val byEmail = mutableMapOf<String, MutableList<Long>>()
29 + val byPhone = mutableMapOf<String, MutableList<Long>>()
30 + for (c in contacts) {
31 + c.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }
32 + .forEach { byEmail.getOrPut(it) { mutableListOf() }.add(c.id) }
33 + c.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }
34 + .forEach { byPhone.getOrPut(it) { mutableListOf() }.add(c.id) }
35 + }
36 + fun linkGroups(groups: Collection<List<Long>>) {
37 + for (g in groups) {
38 + for (i in 1 until g.size) {
39 + if (!excludedPair(g[0], g[i])) union(g[0], g[i])
40 + }
41 + }
42 + }
43 + linkGroups(byEmail.values)
44 + linkGroups(byPhone.values)
45 + val rootToMembers = mutableMapOf<Long, MutableSet<Long>>()
46 + for (c in contacts) {
47 + rootToMembers.getOrPut(find(c.id)) { mutableSetOf() }.add(c.id)
48 + }
49 + val idToCluster = mutableMapOf<Long, Set<Long>>()
50 + for (members in rootToMembers.values) {
51 + for (id in members) idToCluster[id] = members
52 + }
53 + return idToCluster
54 + }
55 +
56 + fun duplicateCount(contactId: Long, clusters: Map<Long, Set<Long>>): Int =
57 + ((clusters[contactId]?.size ?: 1) - 1).coerceAtLeast(0)
58 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt
+41 -0
@@ -0,0 +1,41 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import android.graphics.Bitmap
5 +import fr.ebii.card2vcf.scan.ImageOrientation
6 +import java.io.File
7 +import java.io.FileOutputStream
8 +
9 +class ContactImageStore(private val context: Context) {
10 + private fun dir(id: Long): File =
11 + File(context.filesDir, "contacts/$id").also { it.mkdirs() }
12 +
13 + fun saveJpeg(contactId: Long, name: String, bitmap: Bitmap, quality: Int = 90): String {
14 + val file = File(dir(contactId), name)
15 + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
16 + return file.absolutePath
17 + }
18 +
19 + /** Écrit des octets bruts (ex. image téléchargée depuis le serveur). */
20 + fun saveBytes(contactId: Long, name: String, bytes: ByteArray): String {
21 + val file = File(dir(contactId), name)
22 + file.writeBytes(bytes)
23 + return file.absolutePath
24 + }
25 +
26 + fun deleteAll(contactId: Long) {
27 + dir(contactId).deleteRecursively()
28 + }
29 +
30 + /**
31 + * Copie en ré-encodant après correction EXIF, pour que le fichier stocké
32 + * soit déjà « droit ».
33 + */
34 + fun copyImage(fromPath: String?, toContactId: Long, name: String): String? {
35 + if (fromPath.isNullOrBlank()) return null
36 + val src = File(fromPath)
37 + if (!src.isFile) return null
38 + val oriented = ImageOrientation.fromFile(src)?.bitmap ?: return null
39 + return saveJpeg(toContactId, name, oriented)
40 + }
41 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+277 -0
@@ -0,0 +1,277 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Matrix
5 +import fr.ebii.card2vcf.contact.ContactCard
6 +import fr.ebii.card2vcf.crm.ContactMerge
7 +import fr.ebii.card2vcf.crm.ContactNormalizer
8 +import fr.ebii.card2vcf.crm.MergeFieldChoices
9 +import fr.ebii.card2vcf.scan.ImageOrientation
10 +import fr.ebii.card2vcf.sync.ContactSyncMapper
11 +import fr.ebii.card2vcf.sync.SyncOpDao
12 +import fr.ebii.card2vcf.sync.SyncOpEntity
13 +import java.io.File
14 +import kotlinx.coroutines.flow.Flow
15 +
16 +data class VcfImportResult(
17 + val imported: Int,
18 + val skippedDuplicates: Int,
19 + val errors: Int,
20 +)
21 +
22 +class ContactRepository(
23 + private val dao: CrmContactDao,
24 + private val images: ContactImageStore,
25 + private val syncOpDao: SyncOpDao? = null,
26 + private val isSyncEnabled: () -> Boolean = { false },
27 +) {
28 + fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll()
29 +
30 + suspend fun getById(id: Long) = dao.getById(id)
31 +
32 + suspend fun getExclusions(): List<DuplicateExclusionEntity> = dao.getExclusions()
33 +
34 + suspend fun search(query: String): List<CrmContactEntity> {
35 + val q = query.trim()
36 + if (q.isEmpty()) return dao.getAll()
37 + // Tokens alphanumériques seulement — évite les crashes FTS MATCH sur `"` `*` `:` etc.
38 + val tokens = q.split(Regex("\\s+"))
39 + .map { token -> token.filter { it.isLetterOrDigit() } }
40 + .filter { it.isNotEmpty() }
41 + if (tokens.isEmpty()) return emptyList()
42 + val token = tokens.joinToString(" ") { "$it*" }
43 + val ids = dao.searchIds(token)
44 + return ids.mapNotNull { dao.getById(it) }
45 + }
46 +
47 + suspend fun insertFromCard(
48 + card: ContactCard,
49 + cardBitmap: Bitmap?,
50 + profileBitmap: Bitmap?,
51 + notesOverride: String? = card.note,
52 + now: Long = System.currentTimeMillis(),
53 + ): Long {
54 + val entity = card.toEntity(notes = notesOverride, now = now)
55 + val id = dao.insert(entity)
56 + var updated = entity.copy(id = id)
57 + if (cardBitmap != null) {
58 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
59 + }
60 + if (profileBitmap != null) {
61 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
62 + }
63 + if (updated != entity.copy(id = id)) dao.update(updated)
64 + dao.getById(id)?.let { enqueueContactCreate(it) }
65 + return id
66 + }
67 +
68 + suspend fun updateFromCard(
69 + id: Long,
70 + card: ContactCard,
71 + cardBitmap: Bitmap?,
72 + profileBitmap: Bitmap?,
73 + preserveNotesIfBlankDraft: Boolean = true,
74 + now: Long = System.currentTimeMillis(),
75 + ) {
76 + val existing = dao.getById(id) ?: return
77 + val notes = when {
78 + preserveNotesIfBlankDraft && card.note.isNullOrBlank() -> existing.notes
79 + else -> card.note
80 + }
81 + var updated = card.toEntity(notes = notes, now = now).copy(
82 + id = id,
83 + createdAt = existing.createdAt,
84 + cardImagePath = existing.cardImagePath,
85 + profileImagePath = existing.profileImagePath,
86 + )
87 + if (cardBitmap != null) {
88 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
89 + }
90 + if (profileBitmap != null) {
91 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
92 + }
93 + dao.update(updated)
94 + dao.getById(id)?.let { enqueueContactUpdate(it) }
95 + }
96 +
97 + suspend fun delete(id: Long) {
98 + val existing = dao.getById(id)
99 + dao.deleteExclusionsFor(id)
100 + dao.deleteById(id)
101 + images.deleteAll(id)
102 + if (existing != null) enqueueContactDelete(existing)
103 + }
104 +
105 + /** Tourne la photo carte de [degrees] (ex. -90 = 90° à gauche) et réécrit card.jpg. */
106 + suspend fun rotateCardImage(id: Long, degrees: Int = -90) {
107 + val existing = dao.getById(id) ?: return
108 + val path = existing.cardImagePath?.takeIf { it.isNotBlank() } ?: return
109 + val src = ImageOrientation.fromFile(File(path))?.bitmap ?: return
110 + val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
111 + val rotated = Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
112 + val newPath = images.saveJpeg(id, "card.jpg", rotated)
113 + dao.update(
114 + existing.copy(
115 + cardImagePath = newPath,
116 + updatedAt = System.currentTimeMillis(),
117 + ),
118 + )
119 + dao.getById(id)?.let { enqueueContactUpdate(it) }
120 + }
121 +
122 + suspend fun markDifferent(a: Long, b: Long) {
123 + val lo = minOf(a, b)
124 + val hi = maxOf(a, b)
125 + dao.insertExclusion(DuplicateExclusionEntity(idA = lo, idB = hi))
126 + }
127 +
128 + suspend fun mergeContacts(
129 + sources: List<CrmContactEntity>,
130 + choices: MergeFieldChoices,
131 + ) {
132 + val map = sources.associateBy { it.id }
133 + var merged = ContactMerge.merge(map, choices)
134 + val keepId = choices.keepId
135 + choices.cardImageFromId?.let { srcId ->
136 + val path = map[srcId]?.cardImagePath
137 + val copied = images.copyImage(path, keepId, "card.jpg")
138 + if (copied != null) merged = merged.copy(cardImagePath = copied)
139 + }
140 + choices.profileImageFromId?.let { srcId ->
141 + val path = map[srcId]?.profileImagePath
142 + val copied = images.copyImage(path, keepId, "profile.jpg")
143 + if (copied != null) merged = merged.copy(profileImagePath = copied)
144 + }
145 + dao.update(merged)
146 + for (s in sources) {
147 + if (s.id != keepId) delete(s.id)
148 + }
149 + }
150 +
151 + suspend fun findMatchingId(card: ContactCard, among: List<CrmContactEntity>): Long? {
152 + val emails = card.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }.toSet()
153 + val phones = card.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }.toSet()
154 + for (e in among) {
155 + val eEmails = e.emails.map(ContactNormalizer::email).toSet()
156 + val ePhones = e.phones.map(ContactNormalizer::phone).toSet()
157 + if (emails.any { it in eEmails } || phones.any { it in ePhones }) return e.id
158 + }
159 + return null
160 + }
161 +
162 + suspend fun importCards(cards: List<ContactCard>): VcfImportResult {
163 + var imported = 0
164 + var skipped = 0
165 + var errors = 0
166 + val existing = dao.getAll().toMutableList()
167 + for (card in cards) {
168 + try {
169 + if (!card.hasAnyField()) {
170 + errors++
171 + continue
172 + }
173 + if (findMatchingId(card, existing) != null) {
174 + skipped++
175 + continue
176 + }
177 + val id = insertFromCard(card, null, null)
178 + existing.add(dao.getById(id)!!)
179 + imported++
180 + } catch (_: Exception) {
181 + errors++
182 + }
183 + }
184 + return VcfImportResult(imported, skipped, errors)
185 + }
186 +
187 + private suspend fun enqueueContactCreate(contact: CrmContactEntity) {
188 + if (!isSyncEnabled() || syncOpDao == null) return
189 + syncOpDao.insert(
190 + SyncOpEntity(
191 + entityType = "contact",
192 + op = "create",
193 + payloadJson = ContactSyncMapper.toCreatePayload(contact),
194 + localId = contact.id,
195 + createdAt = System.currentTimeMillis(),
196 + ),
197 + )
198 + }
199 +
200 + /** Si pas encore synchronisé, patche le create en attente (comme les tâches kanban). */
201 + private suspend fun enqueueContactUpdate(contact: CrmContactEntity) {
202 + if (!isSyncEnabled() || syncOpDao == null) return
203 + val serverId = contact.serverId
204 + if (serverId != null) {
205 + syncOpDao.insert(
206 + SyncOpEntity(
207 + entityType = "contact",
208 + op = "update",
209 + payloadJson = ContactSyncMapper.toUpdatePayload(contact),
210 + serverId = serverId,
211 + createdAt = System.currentTimeMillis(),
212 + ),
213 + )
214 + return
215 + }
216 + val pendingCreate = syncOpDao.listAll()
217 + .find { it.entityType == "contact" && it.op == "create" && it.localId == contact.id }
218 + if (pendingCreate != null) {
219 + syncOpDao.insert(
220 + pendingCreate.copy(payloadJson = ContactSyncMapper.toCreatePayload(contact)),
221 + )
222 + }
223 + }
224 +
225 + private suspend fun enqueueContactDelete(contact: CrmContactEntity) {
226 + if (!isSyncEnabled() || syncOpDao == null) return
227 + val serverId = contact.serverId
228 + if (serverId != null) {
229 + syncOpDao.insert(
230 + SyncOpEntity(
231 + entityType = "contact",
232 + op = "delete",
233 + serverId = serverId,
234 + createdAt = System.currentTimeMillis(),
235 + ),
236 + )
237 + } else {
238 + clearPendingContactOps(contact.id)
239 + }
240 + }
241 +
242 + private suspend fun clearPendingContactOps(localId: Long) {
243 + syncOpDao?.listAll()
244 + ?.filter { it.entityType == "contact" && it.localId == localId }
245 + ?.forEach { syncOpDao.deleteById(it.id) }
246 + }
247 +}
248 +
249 +fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity(
250 + fullName = fullName,
251 + firstName = firstName,
252 + lastName = lastName,
253 + company = company,
254 + jobTitle = jobTitle,
255 + phones = phones,
256 + emails = emails,
257 + website = website,
258 + address = address,
259 + notes = notes,
260 + phonesText = phones.joinToString(" "),
261 + emailsText = emails.joinToString(" "),
262 + createdAt = now,
263 + updatedAt = now,
264 +)
265 +
266 +fun CrmContactEntity.toCard() = ContactCard(
267 + fullName = fullName,
268 + firstName = firstName,
269 + lastName = lastName,
270 + company = company,
271 + jobTitle = jobTitle,
272 + phones = phones,
273 + emails = emails,
274 + website = website,
275 + address = address,
276 + note = notes,
277 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/Converters.kt
+22 -0
@@ -0,0 +1,22 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.TypeConverter
4 +import org.json.JSONArray
5 +
6 +class Converters {
7 + @TypeConverter
8 + fun fromStringList(value: List<String>?): String {
9 + val arr = JSONArray()
10 + (value ?: emptyList()).forEach { arr.put(it) }
11 + return arr.toString()
12 + }
13 +
14 + @TypeConverter
15 + fun toStringList(value: String?): List<String> {
16 + if (value.isNullOrBlank()) return emptyList()
17 + val arr = JSONArray(value)
18 + return buildList {
19 + for (i in 0 until arr.length()) add(arr.getString(i))
20 + }
21 + }
22 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactDao.kt
+52 -0
@@ -0,0 +1,52 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import androidx.room.Update
8 +import kotlinx.coroutines.flow.Flow
9 +
10 +@Dao
11 +interface CrmContactDao {
12 + @Insert(onConflict = OnConflictStrategy.REPLACE)
13 + suspend fun insert(entity: CrmContactEntity): Long
14 +
15 + @Update
16 + suspend fun update(entity: CrmContactEntity)
17 +
18 + @Query("DELETE FROM crm_contacts WHERE id = :id")
19 + suspend fun deleteById(id: Long)
20 +
21 + @Query("SELECT * FROM crm_contacts WHERE id = :id")
22 + suspend fun getById(id: Long): CrmContactEntity?
23 +
24 + @Query("SELECT * FROM crm_contacts WHERE serverId = :serverId")
25 + suspend fun getByServerId(serverId: String): CrmContactEntity?
26 +
27 + @Query("DELETE FROM crm_contacts WHERE serverId = :serverId")
28 + suspend fun deleteByServerId(serverId: String)
29 +
30 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
31 + fun observeAll(): Flow<List<CrmContactEntity>>
32 +
33 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
34 + suspend fun getAll(): List<CrmContactEntity>
35 +
36 + @Query(
37 + """
38 + SELECT rowid FROM crm_contacts_fts
39 + WHERE crm_contacts_fts MATCH :query
40 + """,
41 + )
42 + suspend fun searchIds(query: String): List<Long>
43 +
44 + @Insert(onConflict = OnConflictStrategy.IGNORE)
45 + suspend fun insertExclusion(ex: DuplicateExclusionEntity): Long
46 +
47 + @Query("SELECT * FROM duplicate_exclusions")
48 + suspend fun getExclusions(): List<DuplicateExclusionEntity>
49 +
50 + @Query("DELETE FROM duplicate_exclusions WHERE idA = :id OR idB = :id")
51 + suspend fun deleteExclusionsFor(id: Long)
52 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactEntity.kt
+30 -0
@@ -0,0 +1,30 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "crm_contacts")
7 +data class CrmContactEntity(
8 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
9 + val fullName: String? = null,
10 + val firstName: String? = null,
11 + val lastName: String? = null,
12 + val company: String? = null,
13 + val jobTitle: String? = null,
14 + val phones: List<String> = emptyList(),
15 + val emails: List<String> = emptyList(),
16 + val website: String? = null,
17 + val address: String? = null,
18 + val notes: String? = null,
19 + val cardImagePath: String? = null,
20 + val profileImagePath: String? = null,
21 + val createdAt: Long = 0L,
22 + val updatedAt: Long = 0L,
23 + val phonesText: String = "", // space-joined, pour FTS
24 + val emailsText: String = "",
25 + val serverId: String? = null,
26 + val statut: String? = null,
27 + val etape: String? = null,
28 + val tags: List<String> = emptyList(),
29 + val entrepriseServerId: String? = null,
30 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactFts.kt
+19 -0
@@ -0,0 +1,19 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.Fts4
5 +
6 +@Fts4(contentEntity = CrmContactEntity::class)
7 +@Entity(tableName = "crm_contacts_fts")
8 +data class CrmContactFts(
9 + val fullName: String?,
10 + val firstName: String?,
11 + val lastName: String?,
12 + val company: String?,
13 + val jobTitle: String?,
14 + val website: String?,
15 + val address: String?,
16 + val notes: String?,
17 + val phonesText: String?,
18 + val emailsText: String?,
19 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+65 -0
@@ -0,0 +1,65 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Database
5 +import androidx.room.Room
6 +import androidx.room.RoomDatabase
7 +import androidx.room.TypeConverters
8 +import fr.ebii.card2vcf.sync.SyncMetaDao
9 +import fr.ebii.card2vcf.sync.SyncMetaEntity
10 +import fr.ebii.card2vcf.sync.SyncOpDao
11 +import fr.ebii.card2vcf.sync.SyncOpEntity
12 +
13 +@Database(
14 + entities = [
15 + CrmContactEntity::class,
16 + CrmContactFts::class,
17 + DuplicateExclusionEntity::class,
18 + EntrepriseEntity::class,
19 + ProjetEntity::class,
20 + TacheEntity::class,
21 + InteractionEntity::class,
22 + WorkflowEntity::class,
23 + SyncOpEntity::class,
24 + SyncMetaEntity::class,
25 + RdvEntity::class,
26 + ReservationEntity::class,
27 + IndisponibiliteEntity::class,
28 + ],
29 + version = 3,
30 + exportSchema = false,
31 +)
32 +@TypeConverters(Converters::class)
33 +abstract class CrmDatabase : RoomDatabase() {
34 + abstract fun crmContactDao(): CrmContactDao
35 + abstract fun entrepriseDao(): EntrepriseDao
36 + abstract fun projetDao(): ProjetDao
37 + abstract fun tacheDao(): TacheDao
38 + abstract fun interactionDao(): InteractionDao
39 + abstract fun workflowDao(): WorkflowDao
40 + abstract fun syncOpDao(): SyncOpDao
41 + abstract fun syncMetaDao(): SyncMetaDao
42 + abstract fun rdvDao(): RdvDao
43 + abstract fun reservationDao(): ReservationDao
44 + abstract fun indisponibiliteDao(): IndisponibiliteDao
45 +
46 + companion object {
47 + @Volatile private var instance: CrmDatabase? = null
48 +
49 + /**
50 + * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3).
51 + * Les tests utilisent Room.inMemoryDatabaseBuilder sans fallback destructif.
52 + */
53 + fun get(context: Context): CrmDatabase =
54 + instance ?: synchronized(this) {
55 + instance ?: Room.databaseBuilder(
56 + context.applicationContext,
57 + CrmDatabase::class.java,
58 + "card2vcf-crm.db",
59 + )
60 + .fallbackToDestructiveMigration()
61 + .build()
62 + .also { instance = it }
63 + }
64 + }
65 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/DuplicateExclusionEntity.kt
+15 -0
@@ -0,0 +1,15 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.Index
5 +import androidx.room.PrimaryKey
6 +
7 +@Entity(
8 + tableName = "duplicate_exclusions",
9 + indices = [Index(value = ["idA", "idB"], unique = true)],
10 +)
11 +data class DuplicateExclusionEntity(
12 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
13 + val idA: Long, // always idA < idB
14 + val idB: Long,
15 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/EntrepriseDao.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface EntrepriseDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: EntrepriseEntity): Long
12 +
13 + @Query("SELECT * FROM entreprises WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): EntrepriseEntity?
15 +
16 + @Query("SELECT * FROM entreprises WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): EntrepriseEntity?
18 +
19 + @Query("DELETE FROM entreprises WHERE serverId = :serverId")
20 + suspend fun deleteByServerId(serverId: String)
21 +
22 + @Query("SELECT * FROM entreprises")
23 + suspend fun listAll(): List<EntrepriseEntity>
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/EntrepriseEntity.kt
+15 -0
@@ -0,0 +1,15 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "entreprises")
7 +data class EntrepriseEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val nom: String = "",
11 + val secteur: String = "",
12 + val creePar: String = "",
13 + val createdAt: Long = 0L,
14 + val updatedAt: Long = 0L,
15 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/IndisponibiliteDao.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface IndisponibiliteDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: IndisponibiliteEntity): Long
12 +
13 + @Query("SELECT * FROM indisponibilites WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): IndisponibiliteEntity?
15 +
16 + @Query("DELETE FROM indisponibilites WHERE serverId = :serverId")
17 + suspend fun deleteByServerId(serverId: String)
18 +
19 + @Query("SELECT * FROM indisponibilites WHERE cibleType = :cibleType AND cibleId = :cibleId")
20 + suspend fun listByCible(cibleType: String, cibleId: String): List<IndisponibiliteEntity>
21 +
22 + @Query("SELECT * FROM indisponibilites")
23 + suspend fun listAll(): List<IndisponibiliteEntity>
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/IndisponibiliteEntity.kt
+19 -0
@@ -0,0 +1,19 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +/** Miroir lecture seule ; pas de push local (indispos gérées côté serveur). */
7 +@Entity(tableName = "indisponibilites")
8 +data class IndisponibiliteEntity(
9 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
10 + val serverId: String? = null,
11 + val cibleType: String = "",
12 + val cibleId: String = "",
13 + val debutMs: Long = 0L,
14 + val finMs: Long = 0L,
15 + val nature: String? = null,
16 + val commentaire: String? = null,
17 + val updatedAt: Long = 0L,
18 + val calendarEventId: Long? = null,
19 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/InteractionDao.kt
+28 -0
@@ -0,0 +1,28 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface InteractionDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: InteractionEntity): Long
13 +
14 + @Query("SELECT * FROM interactions WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): InteractionEntity?
16 +
17 + @Query("DELETE FROM interactions WHERE serverId = :serverId")
18 + suspend fun deleteByServerId(serverId: String)
19 +
20 + @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId")
21 + suspend fun listByContactServerId(contactServerId: String): List<InteractionEntity>
22 +
23 + @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId ORDER BY createdAt DESC")
24 + fun observeByContactServerId(contactServerId: String): Flow<List<InteractionEntity>>
25 +
26 + @Query("SELECT * FROM interactions")
27 + suspend fun listAll(): List<InteractionEntity>
28 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/InteractionEntity.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "interactions")
7 +data class InteractionEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val contactServerId: String = "",
11 + val type: String = "note",
12 + val sujet: String = "",
13 + val description: String = "",
14 + val creePar: String = "",
15 + val createdAt: Long = 0L,
16 + val updatedAt: Long? = null,
17 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/ProjetDao.kt
+28 -0
@@ -0,0 +1,28 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface ProjetDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: ProjetEntity): Long
13 +
14 + @Query("SELECT * FROM projets WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): ProjetEntity?
16 +
17 + @Query("SELECT * FROM projets WHERE localId = :localId")
18 + suspend fun getByLocalId(localId: Long): ProjetEntity?
19 +
20 + @Query("DELETE FROM projets WHERE serverId = :serverId")
21 + suspend fun deleteByServerId(serverId: String)
22 +
23 + @Query("SELECT * FROM projets")
24 + suspend fun listAll(): List<ProjetEntity>
25 +
26 + @Query("SELECT * FROM projets ORDER BY nom ASC")
27 + fun observeAll(): Flow<List<ProjetEntity>>
28 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ProjetEntity.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "projets")
7 +data class ProjetEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val nom: String = "",
11 + val description: String = "",
12 + val workflowServerId: String = "",
13 + val membresJson: String = "[]",
14 + val creePar: String = "",
15 + val createdAt: Long = 0L,
16 + val updatedAt: Long = 0L,
17 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/RdvDao.kt
+33 -0
@@ -0,0 +1,33 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface RdvDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: RdvEntity): Long
12 +
13 + @Query("SELECT * FROM rdv WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): RdvEntity?
15 +
16 + @Query("SELECT * FROM rdv WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): RdvEntity?
18 +
19 + @Query("SELECT * FROM rdv WHERE calendarEventId = :calendarEventId")
20 + suspend fun getByCalendarEventId(calendarEventId: Long): RdvEntity?
21 +
22 + @Query("SELECT * FROM rdv WHERE dirtyLocal = 1")
23 + suspend fun listDirty(): List<RdvEntity>
24 +
25 + @Query("DELETE FROM rdv WHERE serverId = :serverId")
26 + suspend fun deleteByServerId(serverId: String)
27 +
28 + @Query("DELETE FROM rdv WHERE localId = :localId")
29 + suspend fun deleteByLocalId(localId: Long)
30 +
31 + @Query("SELECT * FROM rdv")
32 + suspend fun listAll(): List<RdvEntity>
33 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/RdvEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "rdv")
7 +data class RdvEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val titre: String = "",
11 + val description: String = "",
12 + val lieu: String = "",
13 + val debutMs: Long = 0L,
14 + val finMs: Long = 0L,
15 + val contactIdsJson: String = "[]",
16 + val projetId: String? = null,
17 + val updatedAt: Long = 0L,
18 + val calendarEventId: Long? = null,
19 + val dirtyLocal: Boolean = false,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/ReservationDao.kt
+36 -0
@@ -0,0 +1,36 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface ReservationDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: ReservationEntity): Long
12 +
13 + @Query("SELECT * FROM reservations WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): ReservationEntity?
15 +
16 + @Query("SELECT * FROM reservations WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): ReservationEntity?
18 +
19 + @Query("SELECT * FROM reservations WHERE calendarEventId = :calendarEventId")
20 + suspend fun getByCalendarEventId(calendarEventId: Long): ReservationEntity?
21 +
22 + @Query("SELECT * FROM reservations WHERE dirtyLocal = 1")
23 + suspend fun listDirty(): List<ReservationEntity>
24 +
25 + @Query("DELETE FROM reservations WHERE serverId = :serverId")
26 + suspend fun deleteByServerId(serverId: String)
27 +
28 + @Query("DELETE FROM reservations WHERE localId = :localId")
29 + suspend fun deleteByLocalId(localId: Long)
30 +
31 + @Query("SELECT * FROM reservations WHERE cibleType = :cibleType AND cibleId = :cibleId")
32 + suspend fun listByCible(cibleType: String, cibleId: String): List<ReservationEntity>
33 +
34 + @Query("SELECT * FROM reservations")
35 + suspend fun listAll(): List<ReservationEntity>
36 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ReservationEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "reservations")
7 +data class ReservationEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val cibleType: String = "",
11 + val cibleId: String = "",
12 + val debutMs: Long = 0L,
13 + val finMs: Long = 0L,
14 + val motif: String = "",
15 + val statut: String = "active",
16 + val updatedAt: Long = 0L,
17 + val calendarEventId: Long? = null,
18 + val dirtyLocal: Boolean = false,
19 + val conflictPending: Boolean = false,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/TacheDao.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface TacheDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: TacheEntity): Long
13 +
14 + @Query("SELECT * FROM taches WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): TacheEntity?
16 +
17 + @Query("SELECT * FROM taches WHERE localId = :localId")
18 + suspend fun getByLocalId(localId: Long): TacheEntity?
19 +
20 + @Query("DELETE FROM taches WHERE serverId = :serverId")
21 + suspend fun deleteByServerId(serverId: String)
22 +
23 + @Query("DELETE FROM taches WHERE localId = :localId")
24 + suspend fun deleteByLocalId(localId: Long)
25 +
26 + @Query("SELECT * FROM taches WHERE projetServerId = :projetServerId")
27 + suspend fun listByProjetServerId(projetServerId: String): List<TacheEntity>
28 +
29 + @Query("SELECT * FROM taches WHERE projetServerId = :projetServerId ORDER BY ordre ASC")
30 + fun observeByProjetServerId(projetServerId: String): Flow<List<TacheEntity>>
31 +
32 + @Query("SELECT * FROM taches")
33 + suspend fun listAll(): List<TacheEntity>
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/TacheEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "taches")
7 +data class TacheEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val projetLocalId: Long? = null,
11 + val projetServerId: String = "",
12 + val titre: String = "",
13 + val description: String = "",
14 + val colonneId: String = "",
15 + val ordre: Int = 0,
16 + val assigneA: String? = null,
17 + val auteur: String = "",
18 + val createdAt: Long = 0L,
19 + val updatedAt: Long = 0L,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/WorkflowDao.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface WorkflowDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: WorkflowEntity)
12 +
13 + @Query("SELECT * FROM workflows WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): WorkflowEntity?
15 +
16 + @Query("DELETE FROM workflows WHERE serverId = :serverId")
17 + suspend fun deleteByServerId(serverId: String)
18 +
19 + @Query("SELECT * FROM workflows")
20 + suspend fun listAll(): List<WorkflowEntity>
21 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/WorkflowEntity.kt
+14 -0
@@ -0,0 +1,14 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "workflows")
7 +data class WorkflowEntity(
8 + @PrimaryKey val serverId: String,
9 + val nom: String = "",
10 + val description: String = "",
11 + val colonnesJson: String = "[]",
12 + val creePar: String = "",
13 + val createdAt: Long = 0L,
14 +)
A android/app/src/main/java/fr/ebii/card2vcf/kanban/KanbanFilter.kt
+13 -0
@@ -0,0 +1,13 @@
1 +package fr.ebii.card2vcf.kanban
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +
5 +/** Filtre « Mes tâches » (défaut) vs « Toutes » — non assigné visible seulement dans Toutes. */
6 +object KanbanFilter {
7 + enum class Mode { MINE, ALL }
8 +
9 + fun filter(tasks: List<TacheEntity>, mode: Mode, userName: String): List<TacheEntity> = when (mode) {
10 + Mode.ALL -> tasks
11 + Mode.MINE -> tasks.filter { it.assigneA == userName }
12 + }
13 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrEngine.kt
+9 -0
@@ -0,0 +1,9 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +
5 +interface OcrEngine {
6 + fun recognize(bitmap: Bitmap): OcrResult
7 +}
8 +
9 +class OcrException(message: String, cause: Throwable? = null) : Exception(message, cause)
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrOrientation.kt
+44 -0
@@ -0,0 +1,44 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Matrix
5 +
6 +/**
7 + * Essaie plusieurs orientations et retient celle qui maximise
8 + * les indices de carte de visite (email, téléphone, URL, volume de texte).
9 + */
10 +object OcrOrientation {
11 + private val angles = intArrayOf(0, 90, 180, 270)
12 +
13 + fun recognizeBest(bitmap: Bitmap, recognize: (Bitmap) -> OcrResult): OcrResult {
14 + var best: OcrResult? = null
15 + var bestScore = Int.MIN_VALUE
16 + for (angle in angles) {
17 + val candidate = if (angle == 0) bitmap else rotate(bitmap, angle)
18 + val result = runCatching { recognize(candidate) }.getOrNull() ?: continue
19 + val score = score(result)
20 + if (score > bestScore) {
21 + bestScore = score
22 + best = result
23 + }
24 + if (candidate !== bitmap) candidate.recycle()
25 + // Assez bon : email + téléphone trouvés
26 + if (result.emails.isNotEmpty() && result.phones.isNotEmpty()) break
27 + }
28 + return best ?: throw OcrException("OCR vide — reprenez la photo")
29 + }
30 +
31 + fun score(result: OcrResult): Int {
32 + var s = result.rawText.length
33 + s += result.emails.size * 200
34 + s += result.phones.size * 150
35 + s += result.urls.size * 100
36 + if (result.rawText.contains('@')) s += 50
37 + return s
38 + }
39 +
40 + fun rotate(src: Bitmap, degrees: Int): Bitmap {
41 + val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
42 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
43 + }
44 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPostProcessor.kt
+72 -0
@@ -0,0 +1,72 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +object OcrPostProcessor {
4 + private val emailRegex = Regex("""[\w.+-]+@[\w.-]+\.\w{2,}""", RegexOption.IGNORE_CASE)
5 + private val urlRegex = Regex(
6 + """(?:https?://[\w./?#&=%+-]+)|(?:www\.[\w.-]+\.\w{2,}(?:/[\w./?#&=%+-]*)?)""",
7 + RegexOption.IGNORE_CASE,
8 + )
9 + private val phoneRegex = Regex("""(?:\+?\d[\d\s.\-()]{6,}\d)""")
10 +
11 + fun process(rawText: String): OcrResult {
12 + val lines = rawText.lines()
13 + .map { it.trim() }
14 + .filter { it.isNotEmpty() }
15 + val joined = lines.joinToString("\n")
16 + // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes).
17 + val phoneSource = joined
18 + .replace(Regex("""(?<=\d)O(?=\d)"""), "0")
19 + .replace(Regex("""\bO(?=\d)"""), "0")
20 + val phones = phoneRegex.findAll(phoneSource)
21 + .map { normalizePhone(it.value) }
22 + .filter { isPlausiblePhone(it) }
23 + .distinct()
24 + .toList()
25 + val emails = emailRegex.findAll(joined).map { it.value.lowercase() }.distinct().toList()
26 + val urls = urlRegex.findAll(joined)
27 + .map { normalizeUrl(it.value) }
28 + .distinct()
29 + .toList()
30 + return OcrResult(
31 + rawText = joined,
32 + lines = lines,
33 + phones = phones,
34 + emails = emails,
35 + urls = urls,
36 + )
37 + }
38 +
39 + fun enrich(base: OcrResult, rawText: String): OcrResult {
40 + val extra = process(rawText)
41 + return base.copy(
42 + rawText = extra.rawText.ifBlank { base.rawText },
43 + lines = extra.lines.ifEmpty { base.lines },
44 + phones = (base.phones + extra.phones).distinct(),
45 + emails = (base.emails + extra.emails).distinct(),
46 + urls = (base.urls + extra.urls).distinct(),
47 + )
48 + }
49 +
50 + internal fun normalizePhone(raw: String): String {
51 + var s = raw.filter { it.isDigit() || it == '+' }
52 + // +33(0)6... → +336...
53 + if (s.startsWith("+33") && s.length > 4 && s[3] == '0') {
54 + s = "+33" + s.substring(4)
55 + }
56 + if (s.startsWith("0033")) {
57 + s = "+33" + s.removePrefix("0033").removePrefix("0")
58 + }
59 + return s
60 + }
61 +
62 + private fun isPlausiblePhone(phone: String): Boolean {
63 + val digits = phone.filter { it.isDigit() }
64 + return digits.length in 10..15
65 + }
66 +
67 + private fun normalizeUrl(raw: String): String {
68 + val t = raw.trim().trimEnd('.', ',', ';')
69 + return if (t.startsWith("http://", true) || t.startsWith("https://", true)) t
70 + else "https://$t"
71 + }
72 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrResult.kt
+10 -0
@@ -0,0 +1,10 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +data class OcrResult(
4 + val rawText: String,
5 + val lines: List<String> = emptyList(),
6 + val phones: List<String> = emptyList(),
7 + val emails: List<String> = emptyList(),
8 + val urls: List<String> = emptyList(),
9 + val confidence: Float? = null,
10 +)
A android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+103 -0
@@ -0,0 +1,103 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.content.Context
4 +import android.graphics.Bitmap
5 +import com.googlecode.tesseract.android.TessBaseAPI
6 +import java.io.File
7 +import java.io.FileOutputStream
8 +
9 +class TesseractOcrEngine(
10 + private val context: Context,
11 + private val languages: String = "fra+deu+eng+spa+por+ita+pol",
12 +) : OcrEngine {
13 +
14 + override fun recognize(bitmap: Bitmap): OcrResult {
15 + val dataPath = ensureTessData()
16 + return OcrOrientation.recognizeBest(bitmap) { candidate ->
17 + recognizeOnce(candidate, dataPath)
18 + }
19 + }
20 +
21 + private fun recognizeOnce(bitmap: Bitmap, dataPath: String): OcrResult {
22 + val api = TessBaseAPI()
23 + try {
24 + if (!api.init(dataPath, languages)) {
25 + throw OcrException("Impossible d'initialiser Tesseract ($languages)")
26 + }
27 + api.pageSegMode = TessBaseAPI.PageSegMode.PSM_AUTO
28 + api.setImage(bitmap)
29 + val raw = api.utF8Text.orEmpty().trim()
30 + if (raw.isBlank()) throw OcrException("OCR vide")
31 + return OcrPostProcessor.process(raw)
32 + } finally {
33 + api.recycle()
34 + }
35 + }
36 +
37 + /**
38 + * Les `.traineddata` vivent dans `filesDir/tesseract/tessdata/` :
39 + * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK
40 + * - ensuite → réutilisation locale (sideload / remplacement possible sans rebuild)
41 + * - stamp `external` → ne jamais écraser (chargement ultérieur manuel)
42 + *
43 + * La variante attendue est lue dans l'asset `tesseract/tessdata/variant.txt`
44 + * (écrit par la tâche Gradle `prepareTessdata`), pas via BuildConfig.
45 + */
46 + private fun ensureTessData(): String {
47 + val tessRoot = File(context.filesDir, "tesseract").apply { mkdirs() }
48 + val tessData = File(tessRoot, "tessdata").apply { mkdirs() }
49 + val stamp = File(tessData, VARIANT_STAMP)
50 + val langs = languages.split('+')
51 + val expectedVariant = packagedVariant()
52 + val stampValue = stamp.takeIf { it.isFile }?.readText()?.trim()
53 + val allPresent = langs.all { File(tessData, "$it.traineddata").isFile }
54 +
55 + val syncFromAssets = when {
56 + !allPresent -> true
57 + stampValue == null -> false.also { stamp.writeText(STAMP_EXTERNAL) }
58 + stampValue == STAMP_EXTERNAL -> false
59 + stampValue == expectedVariant -> false
60 + else -> true // ex. fast → full après réinstall
61 + }
62 +
63 + if (syncFromAssets) {
64 + for (lang in langs) {
65 + val assetPath = "tesseract/tessdata/$lang.traineddata"
66 + if (assetExists(assetPath)) {
67 + copyAsset(assetPath, File(tessData, "$lang.traineddata"))
68 + }
69 + }
70 + stamp.writeText(expectedVariant)
71 + }
72 +
73 + val missing = langs.filterNot { File(tessData, "$it.traineddata").isFile }
74 + if (missing.isNotEmpty()) {
75 + throw OcrException(
76 + "Modèle(s) OCR manquant(s) : ${missing.joinToString { "$it.traineddata" }}. " +
77 + "Recompilez avec -Ptessdata=fast|full, ou déposez les fichiers dans " +
78 + tessData.absolutePath
79 + )
80 + }
81 + return tessRoot.absolutePath
82 + }
83 +
84 + private fun packagedVariant(): String =
85 + runCatching {
86 + context.assets.open(VARIANT_ASSET).bufferedReader().use { it.readText().trim() }
87 + }.getOrDefault("fast")
88 +
89 + private fun assetExists(assetPath: String): Boolean =
90 + runCatching { context.assets.open(assetPath).close(); true }.getOrDefault(false)
91 +
92 + private fun copyAsset(assetPath: String, target: File) {
93 + context.assets.open(assetPath).use { input ->
94 + FileOutputStream(target).use { output -> input.copyTo(output) }
95 + }
96 + }
97 +
98 + companion object {
99 + private const val VARIANT_STAMP = ".card2vcf-tessdata-variant"
100 + private const val VARIANT_ASSET = "tesseract/tessdata/variant.txt"
101 + private const val STAMP_EXTERNAL = "external"
102 + }
103 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/ImageOrientation.kt
+148 -0
@@ -0,0 +1,148 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.BitmapFactory
5 +import android.graphics.Matrix
6 +import androidx.exifinterface.media.ExifInterface
7 +import java.io.ByteArrayInputStream
8 +import java.io.File
9 +import java.io.InputStream
10 +
11 +/**
12 + * Normalise l'orientation d'une photo avant scan/OCR.
13 + *
14 + * Cas couverts (difficultés utilisateur réelles) :
15 + * - EXIF Orientation 1/3/6/8 (0°, 180°, 90°, 270°)
16 + * - miroirs EXIF (FLIP_*)
17 + * - rotation CameraX [ImageInfo.rotationDegrees] si EXIF absent/normal
18 + * - pas de double rotation EXIF + CameraX
19 + * - JPEG décodé via [BitmapFactory.decodeByteArray] (ignore EXIF tout seul)
20 + */
21 +object ImageOrientation {
22 +
23 + data class OrientedBitmap(
24 + val bitmap: Bitmap,
25 + /** Degrés appliqués (0/90/180/270), hors miroirs. */
26 + val appliedRotationDegrees: Int,
27 + /** true si la correction vient de l'EXIF, false si fallback CameraX. */
28 + val fromExif: Boolean,
29 + val exifOrientation: Int,
30 + )
31 +
32 + fun fromJpegBytes(
33 + jpegBytes: ByteArray,
34 + cameraRotationDegrees: Int = 0,
35 + opts: BitmapFactory.Options? = null,
36 + ): OrientedBitmap? {
37 + val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size, opts) ?: return null
38 + val exif = runCatching {
39 + ExifInterface(ByteArrayInputStream(jpegBytes))
40 + }.getOrNull()
41 + return orient(bitmap, exif, cameraRotationDegrees)
42 + }
43 +
44 + fun fromFile(file: File, cameraRotationDegrees: Int = 0): OrientedBitmap? {
45 + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return null
46 + val exif = runCatching { ExifInterface(file) }.getOrNull()
47 + return orient(bitmap, exif, cameraRotationDegrees)
48 + }
49 +
50 + fun fromStream(stream: InputStream, cameraRotationDegrees: Int = 0): OrientedBitmap? {
51 + val bytes = stream.readBytes()
52 + return fromJpegBytes(bytes, cameraRotationDegrees)
53 + }
54 +
55 + /**
56 + * Applique EXIF si présent et non-NORMAL, sinon la rotation caméra.
57 + * Les deux ne sont jamais cumulés.
58 + */
59 + fun orient(
60 + bitmap: Bitmap,
61 + exif: ExifInterface?,
62 + cameraRotationDegrees: Int = 0,
63 + ): OrientedBitmap {
64 + val orientation = exif?.getAttributeInt(
65 + ExifInterface.TAG_ORIENTATION,
66 + ExifInterface.ORIENTATION_UNDEFINED,
67 + ) ?: ExifInterface.ORIENTATION_UNDEFINED
68 +
69 + val exifUseful = orientation != ExifInterface.ORIENTATION_UNDEFINED
70 + && orientation != ExifInterface.ORIENTATION_NORMAL
71 +
72 + return if (exifUseful) {
73 + val transformed = applyExifTransform(bitmap, orientation)
74 + OrientedBitmap(
75 + bitmap = transformed,
76 + appliedRotationDegrees = exifRotationDegrees(orientation),
77 + fromExif = true,
78 + exifOrientation = orientation,
79 + )
80 + } else {
81 + val deg = normalizeDegrees(cameraRotationDegrees)
82 + val transformed = if (deg == 0) bitmap else rotate(bitmap, deg)
83 + OrientedBitmap(
84 + bitmap = transformed,
85 + appliedRotationDegrees = deg,
86 + fromExif = false,
87 + exifOrientation = orientation,
88 + )
89 + }
90 + }
91 +
92 + fun exifRotationDegrees(orientation: Int): Int = when (orientation) {
93 + ExifInterface.ORIENTATION_ROTATE_90,
94 + ExifInterface.ORIENTATION_TRANSPOSE,
95 + -> 90
96 + ExifInterface.ORIENTATION_ROTATE_180,
97 + ExifInterface.ORIENTATION_FLIP_VERTICAL,
98 + -> 180
99 + ExifInterface.ORIENTATION_ROTATE_270,
100 + ExifInterface.ORIENTATION_TRANSVERSE,
101 + -> 270
102 + else -> 0
103 + }
104 +
105 + fun normalizeDegrees(degrees: Int): Int {
106 + var d = degrees % 360
107 + if (d < 0) d += 360
108 + return when (d) {
109 + in 45 until 135 -> 90
110 + in 135 until 225 -> 180
111 + in 225 until 315 -> 270
112 + else -> 0
113 + }
114 + }
115 +
116 + internal fun applyExifTransform(src: Bitmap, orientation: Int): Bitmap {
117 + val matrix = Matrix()
118 + when (orientation) {
119 + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
120 + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
121 + ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
122 + matrix.setRotate(180f)
123 + matrix.postScale(-1f, 1f)
124 + }
125 + ExifInterface.ORIENTATION_TRANSPOSE -> {
126 + matrix.setRotate(90f)
127 + matrix.postScale(-1f, 1f)
128 + }
129 + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
130 + ExifInterface.ORIENTATION_TRANSVERSE -> {
131 + matrix.setRotate(270f)
132 + matrix.postScale(-1f, 1f)
133 + }
134 + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
135 + else -> return src
136 + }
137 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true).also {
138 + if (it !== src && !src.isRecycled) {
139 + // ne recycle pas src : l'appelant peut encore le tenir
140 + }
141 + }
142 + }
143 +
144 + private fun rotate(src: Bitmap, degrees: Int): Bitmap {
145 + val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
146 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
147 + }
148 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/OpenCvScanEngine.kt
+272 -0
@@ -0,0 +1,272 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.PointF
5 +import org.opencv.android.OpenCVLoader
6 +import org.opencv.android.Utils
7 +import org.opencv.core.Mat
8 +import org.opencv.core.MatOfPoint
9 +import org.opencv.core.MatOfPoint2f
10 +import org.opencv.core.Point
11 +import org.opencv.core.Size
12 +import org.opencv.imgproc.Imgproc
13 +import java.util.concurrent.atomic.AtomicBoolean
14 +import kotlin.math.hypot
15 +import kotlin.math.max
16 +
17 +class OpenCvScanEngine : ScanEngine {
18 +
19 + override fun scan(source: Bitmap, manualCorners: ScanCorners?): ScanResult {
20 + if (!ensureOpenCv()) throw ScanException("OpenCV non initialisé")
21 + val src = Mat()
22 + Utils.bitmapToMat(source, src)
23 + try {
24 + val working = resizeIfNeeded(src)
25 + val corners: MatOfPoint2f
26 + val detected: Boolean
27 + if (manualCorners != null) {
28 + corners = manualCorners.toMatPoints()
29 + detected = true
30 + } else {
31 + val found = detectDocumentCorners(working)
32 + if (found != null) {
33 + corners = found
34 + detected = true
35 + } else {
36 + corners = fullFrameCorners(working)
37 + detected = false
38 + }
39 + }
40 + // Pas de deskew via findNonZero : alloue des millions de points et tue l'app (OOM).
41 + val warped = perspectiveCorrect(working, corners)
42 + val outBitmap = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888)
43 + Utils.matToBitmap(warped, outBitmap)
44 + warped.release()
45 + if (working !== src) working.release()
46 + return ScanResult(
47 + bitmap = outBitmap,
48 + angleDegrees = estimateSkewAngle(corners),
49 + confidence = when {
50 + manualCorners != null -> 1f
51 + detected -> 0.8f
52 + else -> 0.4f
53 + },
54 + corners = corners.toScanCorners(),
55 + )
56 + } finally {
57 + src.release()
58 + }
59 + }
60 +
61 + private fun resizeIfNeeded(src: Mat): Mat {
62 + val maxDim = max(src.rows(), src.cols())
63 + if (maxDim <= MAX_DIM) return src.clone()
64 + val scale = MAX_DIM.toDouble() / maxDim
65 + val resized = Mat()
66 + Imgproc.resize(src, resized, Size(src.cols() * scale, src.rows() * scale))
67 + return resized
68 + }
69 +
70 + /** Retourne les coins détectés, ou null si aucune stratégie n'a convergé. */
71 + private fun detectDocumentCorners(src: Mat): MatOfPoint2f? {
72 + val gray = toGray(src)
73 + val imageArea = src.rows() * src.cols()
74 + try {
75 + detectViaAdaptiveThreshold(gray, imageArea)?.let { return it }
76 + detectViaCanny(gray, imageArea)?.let { return it }
77 + detectViaMinAreaRect(gray, imageArea)?.let { return it }
78 + return null
79 + } finally {
80 + gray.release()
81 + }
82 + }
83 +
84 + private fun detectViaAdaptiveThreshold(gray: Mat, imageArea: Int): MatOfPoint2f? {
85 + val blurred = Mat()
86 + val thresh = Mat()
87 + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
88 + try {
89 + Imgproc.GaussianBlur(gray, blurred, Size(5.0, 5.0), 0.0)
90 + Imgproc.adaptiveThreshold(
91 + blurred, thresh, 255.0,
92 + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, 11, 2.0,
93 + )
94 + Imgproc.morphologyEx(thresh, thresh, Imgproc.MORPH_CLOSE, kernel)
95 + return findQuadContour(thresh, imageArea, minAreaRatio = 0.03, epsilons = listOf(0.02, 0.035, 0.05))
96 + } finally {
97 + blurred.release()
98 + thresh.release()
99 + kernel.release()
100 + }
101 + }
102 +
103 + private fun detectViaCanny(gray: Mat, imageArea: Int): MatOfPoint2f? {
104 + val edges = Mat()
105 + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
106 + try {
107 + Imgproc.GaussianBlur(gray, edges, Size(5.0, 5.0), 0.0)
108 + Imgproc.Canny(edges, edges, 40.0, 120.0)
109 + Imgproc.dilate(edges, edges, kernel)
110 + return findQuadContour(edges, imageArea, minAreaRatio = 0.05, epsilons = listOf(0.02, 0.04, 0.06))
111 + } finally {
112 + edges.release()
113 + kernel.release()
114 + }
115 + }
116 +
117 + /** Dernier recours : boîte englobante orientée du plus grand contour. */
118 + private fun detectViaMinAreaRect(gray: Mat, imageArea: Int): MatOfPoint2f? {
119 + val thresh = Mat()
120 + try {
121 + Imgproc.threshold(gray, thresh, 0.0, 255.0, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU)
122 + val contours = mutableListOf<MatOfPoint>()
123 + Imgproc.findContours(thresh, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
124 + val largest = contours.maxByOrNull { Imgproc.contourArea(it) } ?: return null
125 + val area = Imgproc.contourArea(largest)
126 + if (area < imageArea * 0.12) return null
127 + val rect = Imgproc.minAreaRect(MatOfPoint2f(*largest.toArray().map { Point(it.x, it.y) }.toTypedArray()))
128 + val box = MatOfPoint2f()
129 + Imgproc.boxPoints(rect, box)
130 + return orderCorners(box)
131 + } finally {
132 + thresh.release()
133 + }
134 + }
135 +
136 + private fun findQuadContour(
137 + binary: Mat,
138 + imageArea: Int,
139 + minAreaRatio: Double,
140 + epsilons: List<Double>,
141 + ): MatOfPoint2f? {
142 + val contours = mutableListOf<MatOfPoint>()
143 + Imgproc.findContours(binary, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
144 + contours.sortByDescending { Imgproc.contourArea(it) }
145 + val minArea = imageArea * minAreaRatio
146 + for (contour in contours.take(25)) {
147 + val area = Imgproc.contourArea(contour)
148 + if (area < minArea) continue
149 + val curve = MatOfPoint2f(*contour.toArray())
150 + val peri = Imgproc.arcLength(curve, true)
151 + for (eps in epsilons) {
152 + val approx = MatOfPoint2f()
153 + Imgproc.approxPolyDP(curve, approx, eps * peri, true)
154 + if (approx.total() == 4L && isConvexQuad(approx)) {
155 + return orderCorners(approx)
156 + }
157 + }
158 + }
159 + return null
160 + }
161 +
162 + private fun isConvexQuad(approx: MatOfPoint2f): Boolean {
163 + val pts = approx.toArray()
164 + if (pts.size != 4) return false
165 + var sign = 0
166 + for (i in pts.indices) {
167 + val a = pts[i]
168 + val b = pts[(i + 1) % 4]
169 + val c = pts[(i + 2) % 4]
170 + val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)
171 + if (kotlin.math.abs(cross) < 1.0) continue
172 + val s = if (cross > 0) 1 else -1
173 + if (sign == 0) sign = s else if (sign != s) return false
174 + }
175 + return true
176 + }
177 +
178 + private fun fullFrameCorners(src: Mat, marginRatio: Double = 0.02): MatOfPoint2f {
179 + val mx = src.cols() * marginRatio
180 + val my = src.rows() * marginRatio
181 + val w = src.cols() - 1.0
182 + val h = src.rows() - 1.0
183 + return MatOfPoint2f(
184 + Point(mx, my),
185 + Point(w - mx, my),
186 + Point(w - mx, h - my),
187 + Point(mx, h - my),
188 + )
189 + }
190 +
191 + private fun toGray(src: Mat): Mat {
192 + val gray = Mat()
193 + when (src.channels()) {
194 + 4 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
195 + 3 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY)
196 + else -> src.copyTo(gray)
197 + }
198 + return gray
199 + }
200 +
201 + private fun perspectiveCorrect(src: Mat, corners: MatOfPoint2f): Mat {
202 + val pts = corners.toArray()
203 + val widthA = distance(pts[2], pts[3])
204 + val widthB = distance(pts[1], pts[0])
205 + val maxWidth = max(widthA, widthB).toInt().coerceAtLeast(1)
206 + val heightA = distance(pts[1], pts[2])
207 + val heightB = distance(pts[0], pts[3])
208 + val maxHeight = max(heightA, heightB).toInt().coerceAtLeast(1)
209 + val srcPts = MatOfPoint2f(pts[0], pts[1], pts[2], pts[3])
210 + val dstPts = MatOfPoint2f(
211 + Point(0.0, 0.0),
212 + Point(maxWidth - 1.0, 0.0),
213 + Point(maxWidth - 1.0, maxHeight - 1.0),
214 + Point(0.0, maxHeight - 1.0),
215 + )
216 + val matrix = Imgproc.getPerspectiveTransform(srcPts, dstPts)
217 + val out = Mat()
218 + Imgproc.warpPerspective(src, out, matrix, Size(maxWidth.toDouble(), maxHeight.toDouble()))
219 + matrix.release()
220 + return out
221 + }
222 +
223 + private fun orderCorners(approx: MatOfPoint2f): MatOfPoint2f {
224 + val pts = approx.toArray().sortedWith(compareBy({ it.y }, { it.x })).toMutableList()
225 + val top = pts.take(2).sortedBy { it.x }
226 + val bottom = pts.takeLast(2).sortedBy { it.x }
227 + return MatOfPoint2f(top[0], top[1], bottom[1], bottom[0])
228 + }
229 +
230 + private fun estimateSkewAngle(corners: MatOfPoint2f): Float {
231 + val pts = corners.toArray()
232 + val dx = pts[1].x - pts[0].x
233 + val dy = pts[1].y - pts[0].y
234 + return Math.toDegrees(kotlin.math.atan2(dy, dx)).toFloat()
235 + }
236 +
237 + private fun distance(a: Point, b: Point): Double = hypot(a.x - b.x, a.y - b.y)
238 +
239 + private fun ScanCorners.toMatPoints(): MatOfPoint2f =
240 + MatOfPoint2f(
241 + Point(topLeft.x.toDouble(), topLeft.y.toDouble()),
242 + Point(topRight.x.toDouble(), topRight.y.toDouble()),
243 + Point(bottomRight.x.toDouble(), bottomRight.y.toDouble()),
244 + Point(bottomLeft.x.toDouble(), bottomLeft.y.toDouble()),
245 + )
246 +
247 + private fun MatOfPoint2f.toScanCorners(): ScanCorners {
248 + val p = toArray()
249 + return ScanCorners(
250 + topLeft = PointF(p[0].x.toFloat(), p[0].y.toFloat()),
251 + topRight = PointF(p[1].x.toFloat(), p[1].y.toFloat()),
252 + bottomRight = PointF(p[2].x.toFloat(), p[2].y.toFloat()),
253 + bottomLeft = PointF(p[3].x.toFloat(), p[3].y.toFloat()),
254 + )
255 + }
256 +
257 + companion object {
258 + private const val MAX_DIM = 1500
259 + private val initialized = AtomicBoolean(false)
260 +
261 + fun ensureOpenCv(): Boolean {
262 + if (initialized.get()) return true
263 + synchronized(OpenCvScanEngine::class.java) {
264 + if (OpenCVLoader.initLocal()) {
265 + initialized.set(true)
266 + return true
267 + }
268 + }
269 + return false
270 + }
271 + }
272 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/ScanEngine.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.PointF
5 +
6 +data class ScanCorners(
7 + val topLeft: PointF,
8 + val topRight: PointF,
9 + val bottomRight: PointF,
10 + val bottomLeft: PointF,
11 +)
12 +
13 +data class ScanResult(
14 + val bitmap: Bitmap,
15 + val angleDegrees: Float = 0f,
16 + val confidence: Float = 1f,
17 + val corners: ScanCorners? = null,
18 +)
19 +
20 +interface ScanEngine {
21 + fun scan(source: Bitmap, manualCorners: ScanCorners? = null): ScanResult
22 +}
23 +
24 +class ScanException(message: String, cause: Throwable? = null) : Exception(message, cause)
A android/app/src/main/java/fr/ebii/card2vcf/sync/AgendaSyncCoordinator.kt
+431 -0
@@ -0,0 +1,431 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmDatabase
4 +import fr.ebii.card2vcf.data.IndisponibiliteEntity
5 +import fr.ebii.card2vcf.data.RdvEntity
6 +import fr.ebii.card2vcf.data.ReservationEntity
7 +import kotlinx.serialization.decodeFromString
8 +import kotlinx.serialization.encodeToString
9 +
10 +/** Conflit de réservation (409) à faire trancher à l'utilisateur : garder ou `abandonLocalReservation`. */
11 +data class AgendaConflict(
12 + val localReservationId: Long,
13 + val message: String? = null,
14 +)
15 +
16 +/**
17 + * Pont Agenda↔Room et mapping DTO↔entité pour RDV / réservations / indisponibilités.
18 + * Extrait de [SyncEngine] pour garder l'orchestration CRM+agenda lisible ; [SyncEngine] reste
19 + * seul responsable de la file [SyncOpEntity] (push) et du watermark.
20 + */
21 +class AgendaSyncCoordinator(
22 + private val db: CrmDatabase,
23 +) {
24 + /** `salle:id,materiel:id,...` à partir des liaisons non-`rdv` qui portent un `serverResourceId`. */
25 + fun ressourcesQuery(bindings: List<CalendarBinding>): String? =
26 + Companion.ressourcesQuery(bindings)
27 +
28 +
29 + // ---- Agenda -> Room (événements créés/modifiés dans l'app Agenda système) ----
30 +
31 + suspend fun agendaToRoom(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi) {
32 + for (binding in bindings) {
33 + val calendarId = binding.androidCalendarId ?: continue
34 + if (binding.kind == KIND_RDV) {
35 + agendaEventsToRdv(calendarId, bridge)
36 + } else {
37 + agendaEventsToReservation(binding, calendarId, bridge)
38 + }
39 + }
40 + }
41 +
42 + private suspend fun agendaEventsToRdv(calendarId: Long, bridge: CalendarBridgeApi) {
43 + val events = bridge.listEvents(calendarId)
44 + val presentEventIds = events.mapNotNull { it.eventId }.toSet()
45 +
46 + for (snap in events) {
47 + val eventId = snap.eventId ?: continue
48 + val local = db.rdvDao().getByCalendarEventId(eventId)
49 + if (local == null) {
50 + val entity = RdvEntity(
51 + titre = snap.title,
52 + description = snap.descriptionBody,
53 + debutMs = snap.debutMs,
54 + finMs = snap.finMs,
55 + calendarEventId = eventId,
56 + updatedAt = System.currentTimeMillis(),
57 + dirtyLocal = true,
58 + )
59 + val localId = db.rdvDao().upsert(entity)
60 + enqueueOp(KIND_RDV, "create", localId, null, rdvPayload(entity))
61 + continue
62 + }
63 + if (!local.differsFrom(snap)) continue
64 + val updated = local.copy(
65 + titre = snap.title,
66 + description = snap.descriptionBody,
67 + debutMs = snap.debutMs,
68 + finMs = snap.finMs,
69 + updatedAt = System.currentTimeMillis(),
70 + dirtyLocal = true,
71 + )
72 + db.rdvDao().upsert(updated)
73 + enqueueOp(KIND_RDV, if (local.serverId == null) "create" else "update", local.localId, local.serverId, rdvPayload(updated))
74 + }
75 +
76 + // Event supprimé côté app Agenda système : répercuter la suppression (SyncOp si déjà connu du serveur).
77 + for (rdv in db.rdvDao().listAll()) {
78 + val eventId = rdv.calendarEventId ?: continue
79 + if (eventId in presentEventIds) continue
80 + if (rdv.serverId != null) {
81 + enqueueOp(KIND_RDV, "delete", rdv.localId, rdv.serverId, "{}")
82 + }
83 + db.rdvDao().deleteByLocalId(rdv.localId)
84 + }
85 + }
86 +
87 + private fun RdvEntity.differsFrom(snap: CalendarEventSnapshot): Boolean =
88 + titre != snap.title || description != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs
89 +
90 + private suspend fun agendaEventsToReservation(binding: CalendarBinding, calendarId: Long, bridge: CalendarBridgeApi) {
91 + val cibleId = binding.serverResourceId ?: return
92 + val cibleType = binding.kind
93 + val events = bridge.listEvents(calendarId)
94 + val presentEventIds = events.mapNotNull { it.eventId }.toSet()
95 +
96 + for (snap in events) {
97 + val eventId = snap.eventId ?: continue
98 + if (CalendarEventTitles.isIndispoTitle(snap.title)) continue // miroir lecture, pas de push
99 + val local = db.reservationDao().getByCalendarEventId(eventId)
100 + if (local == null) {
101 + val entity = ReservationEntity(
102 + cibleType = cibleType,
103 + cibleId = cibleId,
104 + debutMs = snap.debutMs,
105 + finMs = snap.finMs,
106 + motif = snap.descriptionBody,
107 + calendarEventId = eventId,
108 + updatedAt = System.currentTimeMillis(),
109 + dirtyLocal = true,
110 + )
111 + val localId = db.reservationDao().upsert(entity)
112 + enqueueOp(ENTITY_RESERVATION, "create", localId, null, reservationPayload(entity))
113 + continue
114 + }
115 + if (local.conflictPending) continue // en attente de décision utilisateur, ne pas re-pousser
116 + if (!local.differsFrom(snap)) continue
117 + val updated = local.copy(
118 + debutMs = snap.debutMs,
119 + finMs = snap.finMs,
120 + motif = snap.descriptionBody,
121 + updatedAt = System.currentTimeMillis(),
122 + dirtyLocal = true,
123 + )
124 + db.reservationDao().upsert(updated)
125 + enqueueOp(
126 + ENTITY_RESERVATION,
127 + if (local.serverId == null) "create" else "update",
128 + local.localId,
129 + local.serverId,
130 + reservationPayload(updated),
131 + )
132 + }
133 +
134 + // Event supprimé côté app Agenda système : répercuter la suppression (SyncOp si déjà connu du serveur).
135 + for (reservation in db.reservationDao().listByCible(cibleType, cibleId)) {
136 + val eventId = reservation.calendarEventId ?: continue
137 + if (eventId in presentEventIds) continue
138 + if (reservation.conflictPending) continue // décision utilisateur en attente (voir abandonLocalReservation)
139 + if (reservation.serverId != null) {
140 + enqueueOp(ENTITY_RESERVATION, "delete", reservation.localId, reservation.serverId, cibleRefPayload(cibleType, cibleId))
141 + }
142 + db.reservationDao().deleteByLocalId(reservation.localId)
143 + }
144 + }
145 +
146 + private fun ReservationEntity.differsFrom(snap: CalendarEventSnapshot): Boolean =
147 + motif != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs
148 +
149 + /** Remplace toute op en attente pour cette entité locale avant d'insérer la nouvelle (évite l'accumulation). */
150 + private suspend fun enqueueOp(entityType: String, op: String, localId: Long, serverId: String?, payloadJson: String) {
151 + db.syncOpDao().listAll()
152 + .filter { it.entityType == entityType && it.localId == localId }
153 + .forEach { db.syncOpDao().deleteById(it.id) }
154 + db.syncOpDao().insert(
155 + SyncOpEntity(
156 + entityType = entityType,
157 + op = op,
158 + payloadJson = payloadJson,
159 + localId = localId,
160 + serverId = serverId,
161 + createdAt = System.currentTimeMillis(),
162 + ),
163 + )
164 + }
165 +
166 + private fun rdvPayload(entity: RdvEntity): String {
167 + val contactIds = try {
168 + syncJson.decodeFromString<List<String>>(entity.contactIdsJson)
169 + } catch (_: Exception) {
170 + emptyList()
171 + }
172 + return syncJson.encodeToString(
173 + RdvUpsertRequest(
174 + titre = entity.titre,
175 + description = entity.description,
176 + lieu = entity.lieu,
177 + debut = epochMsToIso(entity.debutMs),
178 + fin = epochMsToIso(entity.finMs),
179 + contactIds = contactIds,
180 + projetId = entity.projetId,
181 + ),
182 + )
183 + }
184 +
185 + private fun reservationPayload(entity: ReservationEntity): String =
186 + syncJson.encodeToString(
187 + ReservationUpsertRequest(
188 + debut = epochMsToIso(entity.debutMs),
189 + fin = epochMsToIso(entity.finMs),
190 + motif = entity.motif,
191 + ),
192 + )
193 +
194 + /**
195 + * Payload d'un SyncOp `delete` réservation : porte `(cibleType, cibleId)` pour que
196 + * [SyncEngine] puisse résoudre le genre/id serveur même après suppression de l'entité locale
197 + * (voir `resolveReservationCible`). Réutilise [CibleRessourceDto], déjà utilisé pour `cible`.
198 + */
199 + private fun cibleRefPayload(cibleType: String, cibleId: String): String =
200 + syncJson.encodeToString(CibleRessourceDto(type = cibleType, id = cibleId))
201 +
202 + // ---- Room -> Agenda (après application du pull) ----
203 +
204 + suspend fun roomToAgenda(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi) {
205 + for (binding in bindings) {
206 + val calendarId = binding.androidCalendarId ?: continue
207 + if (binding.kind == KIND_RDV) {
208 + rdvToAgenda(calendarId, bridge)
209 + } else {
210 + resourceEntitiesToAgenda(binding, calendarId, bridge)
211 + }
212 + }
213 + }
214 +
215 + private suspend fun rdvToAgenda(calendarId: Long, bridge: CalendarBridgeApi) {
216 + for (rdv in db.rdvDao().listAll()) {
217 + val eventId = bridge.upsertEvent(
218 + calendarId,
219 + CalendarEventSnapshot(
220 + title = rdv.titre,
221 + debutMs = rdv.debutMs,
222 + finMs = rdv.finMs,
223 + eventId = rdv.calendarEventId,
224 + serverId = rdv.serverId,
225 + descriptionBody = rdv.description,
226 + ),
227 + )
228 + if (eventId != rdv.calendarEventId) db.rdvDao().upsert(rdv.copy(calendarEventId = eventId))
229 + }
230 + deleteOrphanEvents(calendarId, bridge, rdvKeptIds())
231 + }
232 +
233 + private suspend fun resourceEntitiesToAgenda(binding: CalendarBinding, calendarId: Long, bridge: CalendarBridgeApi) {
234 + val cibleId = binding.serverResourceId ?: return
235 + val cibleType = binding.kind
236 + for (reservation in db.reservationDao().listByCible(cibleType, cibleId)) {
237 + if (reservation.statut != STATUT_ACTIVE) continue
238 + val eventId = bridge.upsertEvent(
239 + calendarId,
240 + CalendarEventSnapshot(
241 + title = reservation.motif.ifBlank { "Réservation" },
242 + debutMs = reservation.debutMs,
243 + finMs = reservation.finMs,
244 + eventId = reservation.calendarEventId,
245 + serverId = reservation.serverId,
246 + ),
247 + )
248 + if (eventId != reservation.calendarEventId) {
249 + db.reservationDao().upsert(reservation.copy(calendarEventId = eventId))
250 + }
251 + }
252 + for (indispo in db.indisponibiliteDao().listByCible(cibleType, cibleId)) {
253 + val eventId = bridge.upsertEvent(
254 + calendarId,
255 + CalendarEventSnapshot(
256 + title = CalendarEventTitles.indispoTitle(indispo.commentaire?.ifBlank { null } ?: indispo.nature ?: "Indisponible"),
257 + debutMs = indispo.debutMs,
258 + finMs = indispo.finMs,
259 + eventId = indispo.calendarEventId,
260 + serverId = indispo.serverId,
261 + ),
262 + )
263 + if (eventId != indispo.calendarEventId) {
264 + db.indisponibiliteDao().upsert(indispo.copy(calendarEventId = eventId))
265 + }
266 + }
267 + deleteOrphanEvents(calendarId, bridge, resourceKeptIds(cibleType, cibleId))
268 + }
269 +
270 + // ---- Events orphelins (Room a supprimé l'entité, l'event Agenda doit suivre) ----
271 +
272 + private suspend fun rdvKeptIds(): Pair<Set<String>, Set<Long>> {
273 + val entities = db.rdvDao().listAll()
274 + return entities.mapNotNull { it.serverId }.toSet() to entities.mapNotNull { it.calendarEventId }.toSet()
275 + }
276 +
277 + private suspend fun resourceKeptIds(cibleType: String, cibleId: String): Pair<Set<String>, Set<Long>> {
278 + val reservations = db.reservationDao().listByCible(cibleType, cibleId).filter { it.statut == STATUT_ACTIVE }
279 + val indispos = db.indisponibiliteDao().listByCible(cibleType, cibleId)
280 + val serverIds = reservations.mapNotNull { it.serverId }.toSet() + indispos.mapNotNull { it.serverId }
281 + val eventIds = reservations.mapNotNull { it.calendarEventId }.toSet() + indispos.mapNotNull { it.calendarEventId }
282 + return serverIds to eventIds
283 + }
284 +
285 + /**
286 + * `deleteEvent` pour tout event `card2vcf:serverId=` (voir [CalendarServerIdCodec]) de [calendarId]
287 + * dont ni le `serverId` ni le `eventId` ne correspondent plus à une entité Room ([kept]).
288 + * Les events non tagués (pas encore poussés, ou étrangers à card2vcf) ne sont jamais supprimés.
289 + */
290 + private fun deleteOrphanEvents(calendarId: Long, bridge: CalendarBridgeApi, kept: Pair<Set<String>, Set<Long>>) {
291 + orphanEvents(calendarId, bridge, kept).forEach { bridge.deleteEvent(it) }
292 + }
293 +
294 + private fun orphanEvents(calendarId: Long, bridge: CalendarBridgeApi, kept: Pair<Set<String>, Set<Long>>): List<Long> {
295 + val (keptServerIds, keptEventIds) = kept
296 + return bridge.listEvents(calendarId).mapNotNull { snap ->
297 + val eventId = snap.eventId ?: return@mapNotNull null
298 + val serverId = snap.serverId ?: return@mapNotNull null // non tagué card2vcf : jamais géré ici
299 + if (serverId in keptServerIds || eventId in keptEventIds) null else eventId
300 + }
301 + }
302 +
303 + /** Nombre d'events card2vcf-tagués dans les calendriers liés qui n'ont plus d'entité Room associée. */
304 + suspend fun orphanEventCount(bindings: List<CalendarBinding>, bridge: CalendarBridgeApi): Int {
305 + var count = 0
306 + for (binding in bindings) {
307 + val calendarId = binding.androidCalendarId ?: continue
308 + val kept = if (binding.kind == KIND_RDV) {
309 + rdvKeptIds()
310 + } else {
311 + val cibleId = binding.serverResourceId ?: continue
312 + resourceKeptIds(binding.kind, cibleId)
313 + }
314 + count += orphanEvents(calendarId, bridge, kept).size
315 + }
316 + return count
317 + }
318 +
319 + // ---- Pull : DTO -> entités (LWW RDV, résas actives, indispos lecture) ----
320 +
321 + suspend fun applyRdvPull(dto: RendezVousDto) {
322 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
323 + val local = db.rdvDao().getByServerId(dto.id)
324 + if (local == null) {
325 + db.rdvDao().upsert(
326 + RdvEntity(
327 + serverId = dto.id,
328 + titre = dto.titre,
329 + description = dto.description,
330 + lieu = dto.lieu,
331 + debutMs = parseIsoToEpochMs(dto.debut),
332 + finMs = parseIsoToEpochMs(dto.fin),
333 + contactIdsJson = syncJson.encodeToString(dto.contactIds),
334 + projetId = dto.projetId,
335 + updatedAt = remoteTs,
336 + ),
337 + )
338 + return
339 + }
340 + val remoteEntity = local.copy(
341 + titre = dto.titre,
342 + description = dto.description,
343 + lieu = dto.lieu,
344 + debutMs = parseIsoToEpochMs(dto.debut),
345 + finMs = parseIsoToEpochMs(dto.fin),
346 + contactIdsJson = syncJson.encodeToString(dto.contactIds),
347 + projetId = dto.projetId,
348 + updatedAt = remoteTs,
349 + dirtyLocal = false,
350 + )
351 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
352 + if (merged !== local) db.rdvDao().upsert(merged)
353 + }
354 +
355 + suspend fun applyReservationPull(dto: ReservationDto) {
356 + val local = db.reservationDao().getByServerId(dto.id)
357 + if (dto.statut != STATUT_ACTIVE) {
358 + if (local != null) db.reservationDao().deleteByServerId(dto.id)
359 + return
360 + }
361 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
362 + if (local == null) {
363 + db.reservationDao().upsert(
364 + ReservationEntity(
365 + serverId = dto.id,
366 + cibleType = dto.cible.type,
367 + cibleId = dto.cible.id,
368 + debutMs = parseIsoToEpochMs(dto.debut),
369 + finMs = parseIsoToEpochMs(dto.fin),
370 + motif = dto.motif,
371 + statut = dto.statut,
372 + updatedAt = remoteTs,
373 + ),
374 + )
375 + return
376 + }
377 + val remoteEntity = local.copy(
378 + cibleType = dto.cible.type,
379 + cibleId = dto.cible.id,
380 + debutMs = parseIsoToEpochMs(dto.debut),
381 + finMs = parseIsoToEpochMs(dto.fin),
382 + motif = dto.motif,
383 + statut = dto.statut,
384 + updatedAt = remoteTs,
385 + dirtyLocal = false,
386 + conflictPending = false,
387 + )
388 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
389 + if (merged !== local) db.reservationDao().upsert(merged)
390 + }
391 +
392 + suspend fun applyIndisponibilitePull(dto: IndisponibiliteDto) {
393 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
394 + val local = db.indisponibiliteDao().getByServerId(dto.id)
395 + val entity = (local ?: IndisponibiliteEntity(serverId = dto.id)).copy(
396 + cibleType = dto.cible.type,
397 + cibleId = dto.cible.id,
398 + debutMs = parseIsoToEpochMs(dto.debut),
399 + finMs = parseIsoToEpochMs(dto.fin),
400 + nature = dto.nature,
401 + commentaire = dto.commentaire,
402 + updatedAt = remoteTs,
403 + )
404 + db.indisponibiliteDao().upsert(entity)
405 + }
406 +
407 + suspend fun applyAgendaTombstone(dto: TombstoneDto): Boolean {
408 + when (dto.entityType) {
409 + "rdv" -> db.rdvDao().deleteByServerId(dto.id)
410 + "reservation" -> db.reservationDao().deleteByServerId(dto.id)
411 + "indisponibilite" -> db.indisponibiliteDao().deleteByServerId(dto.id)
412 + else -> return false
413 + }
414 + return true
415 + }
416 +
417 + companion object {
418 + /** [CalendarBinding.kind] pour la liaison « Mes RDV » ; sert aussi d'`entityType` [SyncOpEntity]. */
419 + const val KIND_RDV = "rdv"
420 + const val ENTITY_RESERVATION = "reservation"
421 + private const val STATUT_ACTIVE = "active"
422 +
423 + /** `salle:id,materiel:id,...` — utilisable sans instance (bandeau status). */
424 + fun ressourcesQuery(bindings: List<CalendarBinding>): String? {
425 + val parts = bindings
426 + .filter { it.kind != KIND_RDV && it.serverResourceId != null }
427 + .map { "${it.kind}:${it.serverResourceId}" }
428 + return parts.takeIf { it.isNotEmpty() }?.joinToString(",")
429 + }
430 + }
431 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+83 -0
@@ -0,0 +1,83 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Contrat HTTP utilisé par [SyncEngine] ; [AilianceApiClient] l'implémente, fakes en test. */
4 +interface AilianceApi {
5 + fun authCle(nom: String, motDePasse: String): AilianceApiClient.ApiResult<AuthCleResponse>
6 +
7 + fun syncStatus(sinceIso: String, ressourcesQuery: String? = null): AilianceApiClient.ApiResult<SyncStatusResponse>
8 +
9 + fun syncPull(sinceIso: String, ressourcesQuery: String? = null): AilianceApiClient.ApiResult<SyncPullResponse>
10 +
11 + fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String>
12 +
13 + fun updateContact(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
14 +
15 + fun deleteContact(id: String): AilianceApiClient.ApiResult<Unit>
16 +
17 + fun createEntreprise(jsonBody: String): AilianceApiClient.ApiResult<String>
18 +
19 + fun updateEntreprise(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
20 +
21 + fun deleteEntreprise(id: String): AilianceApiClient.ApiResult<Unit>
22 +
23 + fun createProjet(jsonBody: String): AilianceApiClient.ApiResult<String>
24 +
25 + fun updateProjet(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
26 +
27 + fun deleteProjet(id: String): AilianceApiClient.ApiResult<Unit>
28 +
29 + fun createTache(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
30 +
31 + fun updateTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
32 +
33 + fun deleteTache(projetId: String, tacheId: String): AilianceApiClient.ApiResult<Unit>
34 +
35 + fun moveTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
36 +
37 + fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
38 +
39 + fun listRdv(): AilianceApiClient.ApiResult<List<RendezVousDto>>
40 +
41 + fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String>
42 +
43 + fun getRdv(id: String): AilianceApiClient.ApiResult<RendezVousDto>
44 +
45 + fun updateRdv(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
46 +
47 + fun deleteRdv(id: String): AilianceApiClient.ApiResult<Unit>
48 +
49 + fun getRessourcesCatalogue(): AilianceApiClient.ApiResult<RessourcesCatalogueDto>
50 +
51 + fun listReservations(genre: String, resourceId: String): AilianceApiClient.ApiResult<List<ReservationDto>>
52 +
53 + fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
54 +
55 + fun updateReservation(
56 + genre: String,
57 + resourceId: String,
58 + reservationId: String,
59 + jsonBody: String,
60 + ): AilianceApiClient.ApiResult<String>
61 +
62 + fun deleteReservation(genre: String, resourceId: String, reservationId: String): AilianceApiClient.ApiResult<Unit>
63 +
64 + fun listIndisponibilites(genre: String, resourceId: String): AilianceApiClient.ApiResult<List<IndisponibiliteDto>>
65 +
66 + fun uploadContactCarte(
67 + id: String,
68 + bytes: ByteArray,
69 + filename: String,
70 + contentType: String = "image/jpeg",
71 + ): AilianceApiClient.ApiResult<String>
72 +
73 + fun uploadContactPhoto(
74 + id: String,
75 + bytes: ByteArray,
76 + filename: String,
77 + contentType: String = "image/jpeg",
78 + ): AilianceApiClient.ApiResult<String>
79 +
80 + fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray>
81 +
82 + fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray>
83 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+323 -0
@@ -0,0 +1,323 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.util.Log
4 +import kotlinx.serialization.builtins.ListSerializer
5 +import okhttp3.Interceptor
6 +import okhttp3.MediaType.Companion.toMediaType
7 +import okhttp3.MultipartBody
8 +import okhttp3.OkHttpClient
9 +import okhttp3.Request
10 +import okhttp3.RequestBody.Companion.toRequestBody
11 +import java.io.IOException
12 +import java.net.Proxy
13 +import java.util.concurrent.TimeUnit
14 +
15 +class AilianceApiClient(
16 + baseUrl: String,
17 + private val apiKey: String? = null,
18 + private val client: OkHttpClient = defaultHttpClient(),
19 +) : AilianceApi {
20 + private val base = baseUrl.trimEnd('/')
21 + private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
22 +
23 + sealed class ApiResult<out T> {
24 + data class Ok<T>(val value: T) : ApiResult<T>()
25 + data class Err(val code: Int, val message: String) : ApiResult<Nothing>()
26 + }
27 +
28 + override fun authCle(nom: String, motDePasse: String): ApiResult<AuthCleResponse> {
29 + val body = syncJson.encodeToString(AuthCleRequest.serializer(), AuthCleRequest(nom, motDePasse))
30 + return post("/api/auth/cle", body, useBearer = false) { responseBody ->
31 + syncJson.decodeFromString(AuthCleResponse.serializer(), responseBody)
32 + }
33 + }
34 +
35 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncStatusResponse> =
36 + get("/api/sync/status?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body ->
37 + syncJson.decodeFromString(SyncStatusResponse.serializer(), body)
38 + }
39 +
40 + override fun syncPull(sinceIso: String, ressourcesQuery: String?): ApiResult<SyncPullResponse> =
41 + get("/api/sync/pull?since=${encodeQuery(sinceIso)}${ressourcesQueryParam(ressourcesQuery)}") { body ->
42 + syncJson.decodeFromString(SyncPullResponse.serializer(), body)
43 + }
44 +
45 + override fun createContact(jsonBody: String): ApiResult<String> =
46 + post("/api/contacts", jsonBody) { it }
47 +
48 + override fun updateContact(id: String, jsonBody: String): ApiResult<String> =
49 + put("/api/contacts/${encodePath(id)}", jsonBody) { it }
50 +
51 + override fun deleteContact(id: String): ApiResult<Unit> =
52 + delete("/api/contacts/${encodePath(id)}")
53 +
54 + override fun createEntreprise(jsonBody: String): ApiResult<String> =
55 + post("/api/entreprises", jsonBody) { it }
56 +
57 + override fun updateEntreprise(id: String, jsonBody: String): ApiResult<String> =
58 + put("/api/entreprises/${encodePath(id)}", jsonBody) { it }
59 +
60 + override fun deleteEntreprise(id: String): ApiResult<Unit> =
61 + delete("/api/entreprises/${encodePath(id)}")
62 +
63 + override fun createProjet(jsonBody: String): ApiResult<String> =
64 + post("/api/projets", jsonBody) { it }
65 +
66 + override fun updateProjet(id: String, jsonBody: String): ApiResult<String> =
67 + put("/api/projets/${encodePath(id)}", jsonBody) { it }
68 +
69 + override fun deleteProjet(id: String): ApiResult<Unit> =
70 + delete("/api/projets/${encodePath(id)}")
71 +
72 + override fun createTache(projetId: String, jsonBody: String): ApiResult<String> =
73 + post("/api/projets/${encodePath(projetId)}/taches", jsonBody) { it }
74 +
75 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> =
76 + put("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}", jsonBody) { it }
77 +
78 + override fun deleteTache(projetId: String, tacheId: String): ApiResult<Unit> =
79 + delete("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}")
80 +
81 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> =
82 + post("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}/deplacer", jsonBody) { it }
83 +
84 + override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> =
85 + post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it }
86 +
87 + override fun listRdv(): ApiResult<List<RendezVousDto>> =
88 + get("/api/rdv") { body -> syncJson.decodeFromString(ListSerializer(RendezVousDto.serializer()), body) }
89 +
90 + override fun createRdv(jsonBody: String): ApiResult<String> =
91 + post("/api/rdv", jsonBody) { it }
92 +
93 + override fun getRdv(id: String): ApiResult<RendezVousDto> =
94 + get("/api/rdv/${encodePath(id)}") { body -> syncJson.decodeFromString(RendezVousDto.serializer(), body) }
95 +
96 + override fun updateRdv(id: String, jsonBody: String): ApiResult<String> =
97 + put("/api/rdv/${encodePath(id)}", jsonBody) { it }
98 +
99 + override fun deleteRdv(id: String): ApiResult<Unit> =
100 + delete("/api/rdv/${encodePath(id)}")
101 +
102 + override fun getRessourcesCatalogue(): ApiResult<RessourcesCatalogueDto> =
103 + get("/api/ressources") { body -> syncJson.decodeFromString(RessourcesCatalogueDto.serializer(), body) }
104 +
105 + override fun listReservations(genre: String, resourceId: String): ApiResult<List<ReservationDto>> =
106 + get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations") { body ->
107 + syncJson.decodeFromString(ListSerializer(ReservationDto.serializer()), body)
108 + }
109 +
110 + override fun createReservation(genre: String, resourceId: String, jsonBody: String): ApiResult<String> =
111 + post("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations", jsonBody) { it }
112 +
113 + override fun updateReservation(
114 + genre: String,
115 + resourceId: String,
116 + reservationId: String,
117 + jsonBody: String,
118 + ): ApiResult<String> =
119 + put(
120 + "/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}",
121 + jsonBody,
122 + ) { it }
123 +
124 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String): ApiResult<Unit> =
125 + delete("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/reservations/${encodePath(reservationId)}")
126 +
127 + override fun listIndisponibilites(genre: String, resourceId: String): ApiResult<List<IndisponibiliteDto>> =
128 + get("/api/ressources/${encodePath(genre)}/${encodePath(resourceId)}/indisponibilites") { body ->
129 + syncJson.decodeFromString(ListSerializer(IndisponibiliteDto.serializer()), body)
130 + }
131 +
132 + override fun uploadContactCarte(
133 + id: String,
134 + bytes: ByteArray,
135 + filename: String,
136 + contentType: String,
137 + ): ApiResult<String> =
138 + uploadMultipart("/api/contacts/${encodePath(id)}/carte", bytes, filename, contentType)
139 +
140 + override fun uploadContactPhoto(
141 + id: String,
142 + bytes: ByteArray,
143 + filename: String,
144 + contentType: String,
145 + ): ApiResult<String> =
146 + uploadMultipart("/api/contacts/${encodePath(id)}/photo", bytes, filename, contentType)
147 +
148 + override fun downloadContactCarte(id: String): ApiResult<ByteArray> =
149 + executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/carte", null, useBearer = true))
150 +
151 + override fun downloadContactPhoto(id: String): ApiResult<ByteArray> =
152 + executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/photo", null, useBearer = true))
153 +
154 + private fun uploadMultipart(
155 + path: String,
156 + bytes: ByteArray,
157 + filename: String,
158 + contentType: String,
159 + ): ApiResult<String> {
160 + val part = MultipartBody.Builder()
161 + .setType(MultipartBody.FORM)
162 + .addFormDataPart(
163 + "file",
164 + filename,
165 + bytes.toRequestBody(contentType.toMediaType()),
166 + )
167 + .build()
168 + val builder = Request.Builder().url("$base$path").post(part)
169 + if (!apiKey.isNullOrBlank()) {
170 + builder.header("Authorization", "Bearer $apiKey")
171 + }
172 + return execute(builder.build()) { it }
173 + }
174 +
175 + private fun executeBytes(request: Request): ApiResult<ByteArray> {
176 + return try {
177 + client.newCall(request).execute().use { response ->
178 + val body = response.body?.bytes() ?: ByteArray(0)
179 + if (response.isSuccessful) {
180 + ApiResult.Ok(body)
181 + } else {
182 + val text = body.toString(Charsets.UTF_8)
183 + ApiResult.Err(response.code, errorMessage(text, response.message))
184 + }
185 + }
186 + } catch (e: IOException) {
187 + ApiResult.Err(-1, e.message ?: "Erreur réseau")
188 + } catch (e: Exception) {
189 + ApiResult.Err(-1, e.message ?: "Erreur inattendue")
190 + }
191 + }
192 +
193 + private inline fun <T> get(path: String, crossinline parse: (String) -> T): ApiResult<T> =
194 + execute(buildRequest("GET", path, null, useBearer = true), parse)
195 +
196 + private inline fun <T> post(
197 + path: String,
198 + jsonBody: String,
199 + useBearer: Boolean = true,
200 + crossinline parse: (String) -> T,
201 + ): ApiResult<T> =
202 + execute(buildRequest("POST", path, jsonBody, useBearer), parse)
203 +
204 + private inline fun <T> put(
205 + path: String,
206 + jsonBody: String,
207 + crossinline parse: (String) -> T,
208 + ): ApiResult<T> =
209 + execute(buildRequest("PUT", path, jsonBody, useBearer = true), parse)
210 +
211 + private fun delete(path: String): ApiResult<Unit> =
212 + when (val result = execute(buildRequest("DELETE", path, null, useBearer = true)) { it }) {
213 + is ApiResult.Ok -> ApiResult.Ok(Unit)
214 + is ApiResult.Err -> result
215 + }
216 +
217 + private fun buildRequest(
218 + method: String,
219 + path: String,
220 + jsonBody: String?,
221 + useBearer: Boolean,
222 + ): Request {
223 + val builder = Request.Builder().url("$base$path")
224 + if (useBearer && !apiKey.isNullOrBlank()) {
225 + builder.header("Authorization", "Bearer $apiKey")
226 + }
227 + when (method) {
228 + "GET" -> builder.get()
229 + "DELETE" -> builder.delete()
230 + "POST" -> builder.post(jsonBody!!.toRequestBody(jsonMediaType))
231 + "PUT" -> builder.put(jsonBody!!.toRequestBody(jsonMediaType))
232 + else -> error("Unsupported method: $method")
233 + }
234 + return builder.build()
235 + }
236 +
237 + private inline fun <T> execute(
238 + request: Request,
239 + crossinline parse: (String) -> T,
240 + ): ApiResult<T> {
241 + return try {
242 + client.newCall(request).execute().use { response ->
243 + val body = response.body?.string().orEmpty()
244 + if (response.isSuccessful) {
245 + ApiResult.Ok(parse(body))
246 + } else {
247 + ApiResult.Err(response.code, errorMessage(body, response.message))
248 + }
249 + }
250 + } catch (e: IOException) {
251 + val detail = e.message ?: "connexion impossible"
252 + val hint =
253 + if (detail.contains("refused", ignoreCase = true) ||
254 + detail.contains("ECONNREFUSED", ignoreCase = true)
255 + ) {
256 + " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)"
257 + } else {
258 + ""
259 + }
260 + ApiResult.Err(-1, "$detail → ${request.method} ${request.url}$hint")
261 + } catch (e: Exception) {
262 + ApiResult.Err(-1, e.message ?: "Erreur inattendue")
263 + }
264 + }
265 +
266 + private fun errorMessage(body: String, fallback: String): String {
267 + if (body.isBlank()) return fallback
268 + return try {
269 + val err = syncJson.decodeFromString(ErrorBody.serializer(), body)
270 + err.message ?: err.error ?: body
271 + } catch (_: Exception) {
272 + body.trim().ifEmpty { fallback }
273 + }
274 + }
275 +
276 + @kotlinx.serialization.Serializable
277 + private data class ErrorBody(
278 + val message: String? = null,
279 + val error: String? = null,
280 + )
281 +
282 + private fun ressourcesQueryParam(ressourcesQuery: String?): String =
283 + if (ressourcesQuery.isNullOrBlank()) "" else "&ressources=${encodeQuery(ressourcesQuery)}"
284 +
285 + companion object {
286 + const val HTTP_LOG_TAG = "Card2vcfHttp"
287 +
288 + /**
289 + * Pas de proxy système : sinon OkHttp peut tenter un proxy Wi‑Fi/VPN qui refuse
290 + * le LAN (`ECONNREFUSED`) alors que le navigateur (souvent hors proxy) fonctionne.
291 + * Interceptor : traces `logcat -s Card2vcfHttp` pour debug téléphone.
292 + */
293 + fun defaultHttpClient(): OkHttpClient =
294 + OkHttpClient.Builder()
295 + .proxy(Proxy.NO_PROXY)
296 + .connectTimeout(15, TimeUnit.SECONDS)
297 + .readTimeout(30, TimeUnit.SECONDS)
298 + .writeTimeout(30, TimeUnit.SECONDS)
299 + .addInterceptor(httpTraceInterceptor())
300 + .build()
301 +
302 + private fun httpTraceInterceptor(): Interceptor = Interceptor { chain ->
303 + val request = chain.request()
304 + Log.i(HTTP_LOG_TAG, "→ ${request.method} ${request.url}")
305 + try {
306 + val response = chain.proceed(request)
307 + Log.i(HTTP_LOG_TAG, "← ${response.code} ${request.url}")
308 + response
309 + } catch (e: Exception) {
310 + Log.e(HTTP_LOG_TAG, "✗ ${request.method} ${request.url}: ${e.javaClass.simpleName}: ${e.message}", e)
311 + throw e
312 + }
313 + }
314 +
315 + private fun encodeQuery(value: String): String =
316 + java.net.URLEncoder.encode(value, Charsets.UTF_8)
317 +
318 + private fun encodePath(segment: String): String =
319 + segment.split("/").joinToString("/") { part ->
320 + java.net.URLEncoder.encode(part, Charsets.UTF_8)
321 + }
322 + }
323 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/ApiCleCrypto.kt
+80 -0
@@ -0,0 +1,80 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.bouncycastle.crypto.generators.Argon2BytesGenerator
4 +import org.bouncycastle.crypto.modes.XChaCha20Poly1305
5 +import org.bouncycastle.crypto.params.AEADParameters
6 +import org.bouncycastle.crypto.params.Argon2Parameters
7 +import org.bouncycastle.crypto.params.KeyParameter
8 +import java.util.Base64
9 +
10 +class ApiCleCryptoException(message: String, cause: Throwable? = null) : Exception(message, cause)
11 +
12 +object ApiCleCrypto {
13 + private const val NONCE_LEN = 24
14 + private const val TAG_LEN = 16
15 + private const val KEY_LEN = 32
16 + private const val SALT_LEN = 16
17 + private const val ARGON2_M_KB = 19456
18 + private const val ARGON2_T = 2
19 + private const val ARGON2_P = 1
20 + private const val MAC_BITS = 128
21 +
22 + fun decryptApiKey(password: String, saltB64: String, cipherB64: String): String {
23 + val salt = decodeB64(saltB64, "sel")
24 + if (salt.size != SALT_LEN) {
25 + throw ApiCleCryptoException("sel invalide")
26 + }
27 +
28 + val blob = decodeB64(cipherB64, "cle_chiffree")
29 + if (blob.size < NONCE_LEN + TAG_LEN) {
30 + throw ApiCleCryptoException("blob trop court")
31 + }
32 +
33 + val key = deriveKey(password, salt)
34 + val nonce = blob.copyOfRange(0, NONCE_LEN)
35 + val ciphertext = blob.copyOfRange(NONCE_LEN, blob.size)
36 +
37 + val plaintext = try {
38 + decryptXChaCha20Poly1305(key, nonce, ciphertext)
39 + } catch (e: Exception) {
40 + throw ApiCleCryptoException("échec déchiffrement clé API", e)
41 + }
42 +
43 + return plaintext.toString(Charsets.UTF_8)
44 + }
45 +
46 + private fun deriveKey(password: String, salt: ByteArray): ByteArray {
47 + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
48 + .withVersion(Argon2Parameters.ARGON2_VERSION_13)
49 + .withIterations(ARGON2_T)
50 + .withMemoryAsKB(ARGON2_M_KB)
51 + .withParallelism(ARGON2_P)
52 + .withSalt(salt)
53 + .build()
54 + val generator = Argon2BytesGenerator()
55 + generator.init(params)
56 + val key = ByteArray(KEY_LEN)
57 + generator.generateBytes(password.toByteArray(Charsets.UTF_8), key)
58 + return key
59 + }
60 +
61 + private fun decryptXChaCha20Poly1305(key: ByteArray, nonce24: ByteArray, ciphertext: ByteArray): ByteArray {
62 + require(nonce24.size == NONCE_LEN)
63 + val cipher = XChaCha20Poly1305()
64 + cipher.init(
65 + false,
66 + AEADParameters(KeyParameter(key), MAC_BITS, nonce24, null),
67 + )
68 + val output = ByteArray(cipher.getOutputSize(ciphertext.size))
69 + var len = cipher.processBytes(ciphertext, 0, ciphertext.size, output, 0)
70 + len += cipher.doFinal(output, len)
71 + return output.copyOf(len)
72 + }
73 +
74 + private fun decodeB64(value: String, label: String): ByteArray =
75 + try {
76 + Base64.getDecoder().decode(value)
77 + } catch (e: IllegalArgumentException) {
78 + throw ApiCleCryptoException("$label base64 invalide", e)
79 + }
80 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBindingsStore.kt
+62 -0
@@ -0,0 +1,62 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import android.content.SharedPreferences
5 +import kotlinx.serialization.Serializable
6 +import kotlinx.serialization.decodeFromString
7 +import kotlinx.serialization.encodeToString
8 +import kotlinx.serialization.json.Json
9 +
10 +/** Liaison entre un calendrier Android local et une entité Projectiaon (RDV ou ressource réservable). */
11 +@Serializable
12 +data class CalendarBinding(
13 + val kind: String, // "rdv" | "salle" | "materiel" | "vehicule"
14 + val serverResourceId: String? = null,
15 + val displayName: String,
16 + val androidCalendarId: Long? = null,
17 +)
18 +
19 +/** Persiste les [CalendarBinding] liés par l'utilisateur en Paramètres, en JSON dans SharedPreferences. */
20 +class CalendarBindingsStore internal constructor(
21 + private val prefs: SharedPreferences,
22 +) {
23 + constructor(context: Context) : this(
24 + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
25 + )
26 +
27 + fun list(): List<CalendarBinding> {
28 + val raw = prefs.getString(KEY_BINDINGS, null) ?: return emptyList()
29 + return try {
30 + json.decodeFromString(raw)
31 + } catch (e: Exception) {
32 + emptyList()
33 + }
34 + }
35 +
36 + /** Remplace la liaison existante de même [CalendarBinding.kind]/[CalendarBinding.serverResourceId], sinon l'ajoute. */
37 + fun upsert(binding: CalendarBinding) {
38 + val updated = list().filterNot { it.matches(binding.kind, binding.serverResourceId) } + binding
39 + save(updated)
40 + }
41 +
42 + fun remove(kind: String, serverResourceId: String?) {
43 + save(list().filterNot { it.matches(kind, serverResourceId) })
44 + }
45 +
46 + fun clear() {
47 + prefs.edit().clear().apply()
48 + }
49 +
50 + private fun save(bindings: List<CalendarBinding>) {
51 + prefs.edit().putString(KEY_BINDINGS, json.encodeToString(bindings)).apply()
52 + }
53 +
54 + private fun CalendarBinding.matches(kind: String, serverResourceId: String?): Boolean =
55 + this.kind == kind && this.serverResourceId == serverResourceId
56 +
57 + internal companion object {
58 + const val PREFS_NAME = "card2vcf_calendar_bindings"
59 + const val KEY_BINDINGS = "bindings"
60 + private val json = Json { ignoreUnknownKeys = true }
61 + }
62 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBridge.kt
+181 -0
@@ -0,0 +1,181 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.ContentResolver
4 +import android.content.ContentUris
5 +import android.content.ContentValues
6 +import android.provider.CalendarContract
7 +import java.util.TimeZone
8 +
9 +/**
10 + * Encode/décode le `serverId` distant d'un événement dans `Events.DESCRIPTION`.
11 + *
12 + * Choix : préfixe de ligne stable plutôt qu'extended properties Calendar, dont la fiabilité varie
13 + * selon les fournisseurs de calendrier hors-GMS (certains ne les exposent pas via `ContentResolver`
14 + * sans compte de synchronisation). La description visible par l'utilisateur ([CalendarEventSnapshot.descriptionBody])
15 + * suit immédiatement la ligne de préfixe.
16 + */
17 +object CalendarServerIdCodec {
18 + private const val PREFIX = "card2vcf:serverId="
19 +
20 + fun encode(serverId: String?, descriptionBody: String): String {
21 + if (serverId == null) return descriptionBody
22 + return "$PREFIX$serverId\n$descriptionBody"
23 + }
24 +
25 + /** Retourne `(serverId, descriptionBody)` ; `serverId` est `null` si le préfixe est absent. */
26 + fun decode(description: String?): Pair<String?, String> {
27 + if (description == null) return null to ""
28 + if (!description.startsWith(PREFIX)) return null to description
29 +
30 + val rest = description.removePrefix(PREFIX)
31 + val newlineIndex = rest.indexOf('\n')
32 + return if (newlineIndex == -1) {
33 + rest to ""
34 + } else {
35 + rest.substring(0, newlineIndex) to rest.substring(newlineIndex + 1)
36 + }
37 + }
38 +}
39 +
40 +/** Préfixe de titre distinguant les indisponibilités (miroir lecture) des réservations/RDV. */
41 +object CalendarEventTitles {
42 + private const val INDISPO_PREFIX = "[Indispo] "
43 +
44 + fun indispoTitle(title: String): String = "$INDISPO_PREFIX$title"
45 +
46 + fun isIndispoTitle(title: String): Boolean = title.startsWith(INDISPO_PREFIX)
47 +
48 + fun stripIndispoPrefix(title: String): String = title.removePrefix(INDISPO_PREFIX)
49 +}
50 +
51 +/** Contrat pont Agenda utilisé par [SyncEngine]/[AgendaSyncCoordinator] ; [CalendarBridge] l'implémente, fake en test. */
52 +interface CalendarBridgeApi {
53 + fun ensureLocalCalendar(displayName: String): Long
54 +
55 + fun listEvents(calendarId: Long): List<CalendarEventSnapshot>
56 +
57 + fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long
58 +
59 + fun deleteEvent(eventId: Long)
60 +}
61 +
62 +/**
63 + * Pont Room ↔ Agenda Android via des calendriers **locaux** (`ACCOUNT_TYPE_LOCAL`), pour rester
64 + * fonctionnel sans compte Google / services Play. Le `serverId` distant est encodé dans
65 + * `Events.DESCRIPTION` (voir [CalendarServerIdCodec]).
66 + */
67 +class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi {
68 +
69 + /** Crée le calendrier local `displayName` s'il n'existe pas encore, et retourne son id. */
70 + override fun ensureLocalCalendar(displayName: String): Long {
71 + findCalendarId(displayName)?.let { return it }
72 +
73 + val uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
74 + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
75 + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
76 + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
77 + .build()
78 +
79 + val values = ContentValues().apply {
80 + put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
81 + put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
82 + put(CalendarContract.Calendars.NAME, displayName)
83 + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, displayName)
84 + put(CalendarContract.Calendars.CALENDAR_COLOR, DEFAULT_COLOR)
85 + put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
86 + put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME)
87 + put(CalendarContract.Calendars.VISIBLE, 1)
88 + put(CalendarContract.Calendars.SYNC_EVENTS, 1)
89 + }
90 +
91 + val created = resolver.insert(uri, values)
92 + ?: error("Échec de création du calendrier local « $displayName »")
93 + return ContentUris.parseId(created)
94 + }
95 +
96 + override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> {
97 + val projection = arrayOf(
98 + CalendarContract.Events._ID,
99 + CalendarContract.Events.TITLE,
100 + CalendarContract.Events.DESCRIPTION,
101 + CalendarContract.Events.DTSTART,
102 + CalendarContract.Events.DTEND,
103 + )
104 + val events = mutableListOf<CalendarEventSnapshot>()
105 + resolver.query(
106 + CalendarContract.Events.CONTENT_URI,
107 + projection,
108 + "${CalendarContract.Events.CALENDAR_ID} = ?",
109 + arrayOf(calendarId.toString()),
110 + null,
111 + )?.use { cursor ->
112 + val idIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events._ID)
113 + val titleIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.TITLE)
114 + val descIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION)
115 + val startIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)
116 + val endIdx = cursor.getColumnIndexOrThrow(CalendarContract.Events.DTEND)
117 + while (cursor.moveToNext()) {
118 + val (serverId, body) = CalendarServerIdCodec.decode(cursor.getString(descIdx))
119 + events += CalendarEventSnapshot(
120 + title = cursor.getString(titleIdx) ?: "",
121 + debutMs = cursor.getLong(startIdx),
122 + finMs = cursor.getLong(endIdx),
123 + eventId = cursor.getLong(idIdx),
124 + serverId = serverId,
125 + descriptionBody = body,
126 + )
127 + }
128 + }
129 + return events
130 + }
131 +
132 + /** Crée l'événement (si `snap.eventId == null`) ou le met à jour, et retourne son id. */
133 + override fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long {
134 + val values = ContentValues().apply {
135 + put(CalendarContract.Events.CALENDAR_ID, calendarId)
136 + put(CalendarContract.Events.TITLE, snap.title)
137 + put(CalendarContract.Events.DESCRIPTION, CalendarServerIdCodec.encode(snap.serverId, snap.descriptionBody))
138 + put(CalendarContract.Events.DTSTART, snap.debutMs)
139 + put(CalendarContract.Events.DTEND, snap.finMs)
140 + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
141 + }
142 +
143 + val existingEventId = snap.eventId
144 + if (existingEventId != null) {
145 + val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, existingEventId)
146 + resolver.update(uri, values, null, null)
147 + return existingEventId
148 + }
149 +
150 + val created = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
151 + ?: error("Échec de création de l'événement « ${snap.title} »")
152 + return ContentUris.parseId(created)
153 + }
154 +
155 + override fun deleteEvent(eventId: Long) {
156 + val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
157 + resolver.delete(uri, null, null)
158 + }
159 +
160 + private fun findCalendarId(displayName: String): Long? {
161 + val projection = arrayOf(CalendarContract.Calendars._ID)
162 + val selection = "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
163 + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
164 + "${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} = ?"
165 + val args = arrayOf(ACCOUNT_NAME, CalendarContract.ACCOUNT_TYPE_LOCAL, displayName)
166 +
167 + return resolver.query(CalendarContract.Calendars.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
168 + if (cursor.moveToFirst()) {
169 + cursor.getLong(cursor.getColumnIndexOrThrow(CalendarContract.Calendars._ID))
170 + } else {
171 + null
172 + }
173 + }
174 + }
175 +
176 + companion object {
177 + /** Compte local dédié Card2vcf, hors-GMS : pas de synchronisation cloud. */
178 + const val ACCOUNT_NAME = "card2vcf"
179 + private const val DEFAULT_COLOR = 0xFF000000.toInt()
180 + }
181 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarEventSnapshot.kt
+11 -0
@@ -0,0 +1,11 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Miroir léger d'un événement `CalendarContract.Events`, indépendant du `ContentResolver`. */
4 +data class CalendarEventSnapshot(
5 + val title: String,
6 + val debutMs: Long,
7 + val finMs: Long,
8 + val eventId: Long? = null,
9 + val serverId: String? = null,
10 + val descriptionBody: String = "",
11 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/ContactSyncMapper.kt
+41 -0
@@ -0,0 +1,41 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlinx.serialization.encodeToString
5 +
6 +/** Mappe un contact Room local vers le JSON `ContactInput` serveur. */
7 +object ContactSyncMapper {
8 + fun toCreatePayload(entity: CrmContactEntity): String =
9 + syncJson.encodeToString(entity.toRequest())
10 +
11 + fun toUpdatePayload(entity: CrmContactEntity): String = toCreatePayload(entity)
12 +
13 + private fun CrmContactEntity.toRequest(): CreateContactRequest {
14 + val (prenom, nom) = resolveNames()
15 + return CreateContactRequest(
16 + prenom = prenom,
17 + nom = nom,
18 + entrepriseId = entrepriseServerId?.takeIf { it.isNotBlank() },
19 + fonction = jobTitle.orEmpty(),
20 + emails = emails.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
21 + telephones = phones.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
22 + notes = notes.orEmpty(),
23 + statut = statut?.takeIf { it.isNotBlank() } ?: "prospect",
24 + etape = etape?.takeIf { it.isNotBlank() } ?: "nouveau",
25 + tags = tags,
26 + )
27 + }
28 +
29 + private fun CrmContactEntity.resolveNames(): Pair<String, String> {
30 + val fn = firstName?.trim().orEmpty()
31 + val ln = lastName?.trim().orEmpty()
32 + if (fn.isNotEmpty() || ln.isNotEmpty()) return fn to ln
33 + val full = fullName?.trim().orEmpty()
34 + if (full.isEmpty()) return "" to ""
35 + val parts = full.split(Regex("\\s+"), limit = 2)
36 + return when (parts.size) {
37 + 1 -> "" to parts[0]
38 + else -> parts[0] to parts.drop(1).joinToString(" ")
39 + }
40 + }
41 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/LwwMerger.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +object LwwMerger {
4 + /** Returns remote if remoteTs >= localTs (remote wins ties), else local. */
5 + fun <T> pickLww(local: T, localTs: Long, remote: T, remoteTs: Long): T =
6 + if (remoteTs >= localTs) remote else local
7 +
8 + data class TaskPlacement(
9 + val colonneId: String,
10 + val ordre: Int,
11 + val updatedAt: Long,
12 + )
13 +
14 + fun pickTaskMove(local: TaskPlacement, remote: TaskPlacement): TaskPlacement =
15 + pickLww(local, local.updatedAt, remote, remote.updatedAt)
16 +
17 + /** Append-only: insert interaction only if serverId is not already known locally. */
18 + fun shouldInsertInteraction(existingServerIds: Set<String>, remoteServerId: String): Boolean =
19 + remoteServerId !in existingServerIds
20 +
21 + /** Caller deletes local rows by serverId; returns remaining ids after tombstone application. */
22 + fun applyTombstoneIds(localServerIds: Set<String>, tombstoneIds: Set<String>): Set<String> =
23 + localServerIds - tombstoneIds
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/ServerUrlPolicy.kt
+59 -0
@@ -0,0 +1,59 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/**
4 + * Politique d'URL serveur : HTTPS obligatoire par défaut.
5 + * Le HTTP clair n'est accepté que si l'utilisateur a coché l'option
6 + * « Autoriser HTTP (clair) » (dev / réseau interne / démo).
7 + */
8 +object ServerUrlPolicy {
9 + const val ERROR_HTTPS_REQUIRED =
10 + "L'URL doit commencer par https:// (cochez « Autoriser HTTP (clair) » pour le développement)."
11 + const val ERROR_INVALID_URL =
12 + "URL invalide : utilisez https://… (ou http://… si HTTP clair autorisé)."
13 + const val ERROR_LAN_HTTPS_NO_PORT =
14 + "URL HTTPS sans port sur une IP locale : Projectiaon écoute en HTTP sur le port 3000. " +
15 + "Utilisez http://192.168.x.x:3000 et cochez « Autoriser HTTP (clair) »."
16 + const val ERROR_LAN_HTTP_NO_PORT =
17 + "URL sans port : Projectiaon écoute sur le port 3000 (ex. http://192.168.x.x:3000)."
18 +
19 + fun isHttps(url: String): Boolean = url.trim().startsWith("https://", ignoreCase = true)
20 +
21 + fun isHttp(url: String): Boolean = url.trim().startsWith("http://", ignoreCase = true)
22 +
23 + /** Racine API : sans slash final ni chemin de test navigateur (`/version`). */
24 + fun normalizeBaseUrl(url: String): String =
25 + url.trim().trimEnd('/').removeSuffix("/version")
26 +
27 + /** `null` si l'URL est acceptable, sinon message d'erreur à afficher. */
28 + fun validate(url: String, allowCleartext: Boolean): String? {
29 + val trimmed = normalizeBaseUrl(url)
30 + if (trimmed.isEmpty()) return ERROR_INVALID_URL
31 + return when {
32 + isHttps(trimmed) -> lanHostWithoutPortError(trimmed)
33 + isHttp(trimmed) -> when {
34 + !allowCleartext -> ERROR_HTTPS_REQUIRED
35 + else -> lanHostWithoutPortError(trimmed)
36 + }
37 + else -> ERROR_INVALID_URL
38 + }
39 + }
40 +
41 + private fun lanHostWithoutPortError(url: String): String? {
42 + val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return null
43 + if (uri.port != -1) return null
44 + val host = uri.host ?: return null
45 + if (!isPrivateLanHost(host)) return null
46 + return when {
47 + isHttps(url) -> ERROR_LAN_HTTPS_NO_PORT
48 + isHttp(url) -> ERROR_LAN_HTTP_NO_PORT
49 + else -> null
50 + }
51 + }
52 +
53 + private fun isPrivateLanHost(host: String): Boolean {
54 + if (host.equals("localhost", ignoreCase = true) || host == "127.0.0.1") return true
55 + if (host.startsWith("192.168.")) return true
56 + if (host.startsWith("10.")) return true
57 + return host.matches(Regex("""172\.(1[6-9]|2\d|3[01])\..+"""))
58 + }
59 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncCredentialsStore.kt
+86 -0
@@ -0,0 +1,86 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import android.content.SharedPreferences
5 +import androidx.security.crypto.EncryptedSharedPreferences
6 +import androidx.security.crypto.MasterKey
7 +
8 +class SyncCredentialsStore internal constructor(
9 + private val prefs: SharedPreferences,
10 +) {
11 + constructor(context: Context) : this(createEncryptedPrefs(context.applicationContext))
12 +
13 + var baseUrl: String?
14 + get() = prefs.getString(KEY_BASE_URL, null)?.takeIf { it.isNotBlank() }
15 + set(value) {
16 + prefs.edit().apply {
17 + if (value.isNullOrBlank()) remove(KEY_BASE_URL) else putString(KEY_BASE_URL, value)
18 + }.apply()
19 + }
20 +
21 + var apiKey: String?
22 + get() = prefs.getString(KEY_API_KEY, null)?.takeIf { it.isNotBlank() }
23 + set(value) {
24 + prefs.edit().apply {
25 + if (value.isNullOrBlank()) remove(KEY_API_KEY) else putString(KEY_API_KEY, value)
26 + }.apply()
27 + }
28 +
29 + var userName: String?
30 + get() = prefs.getString(KEY_USER_NAME, null)?.takeIf { it.isNotBlank() }
31 + set(value) {
32 + prefs.edit().apply {
33 + if (value.isNullOrBlank()) remove(KEY_USER_NAME) else putString(KEY_USER_NAME, value)
34 + }.apply()
35 + }
36 +
37 + /**
38 + * Autorise les URL `http://` (réseau interne / démo / dev).
39 + * Conservé après [clear] : ce n'est pas un secret, juste une préférence locale.
40 + * Défaut : `false` (HTTPS obligatoire).
41 + */
42 + var allowCleartextHttp: Boolean
43 + get() = prefs.getBoolean(KEY_ALLOW_CLEARTEXT, false)
44 + set(value) {
45 + prefs.edit().putBoolean(KEY_ALLOW_CLEARTEXT, value).apply()
46 + }
47 +
48 + fun save(baseUrl: String, apiKey: String, userName: String) {
49 + prefs.edit()
50 + .putString(KEY_BASE_URL, baseUrl)
51 + .putString(KEY_API_KEY, apiKey)
52 + .putString(KEY_USER_NAME, userName)
53 + .apply()
54 + }
55 +
56 + fun clear() {
57 + // Ne pas effacer [allowCleartextHttp] : pratique en démo/interne entre deux connexions.
58 + prefs.edit()
59 + .remove(KEY_BASE_URL)
60 + .remove(KEY_API_KEY)
61 + .remove(KEY_USER_NAME)
62 + .apply()
63 + }
64 +
65 + fun isConfigured(): Boolean =
66 + !baseUrl.isNullOrBlank() && !apiKey.isNullOrBlank() && !userName.isNullOrBlank()
67 +
68 + internal companion object {
69 + const val PREFS_NAME = "card2vcf_sync_creds"
70 + const val KEY_BASE_URL = "base_url"
71 + const val KEY_API_KEY = "api_key"
72 + const val KEY_USER_NAME = "user_name"
73 + const val KEY_ALLOW_CLEARTEXT = "allow_cleartext_http"
74 +
75 + internal fun createEncryptedPrefs(context: Context): SharedPreferences =
76 + EncryptedSharedPreferences.create(
77 + context,
78 + PREFS_NAME,
79 + MasterKey.Builder(context)
80 + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
81 + .build(),
82 + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
83 + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
84 + )
85 + }
86 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+628 -0
@@ -0,0 +1,628 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.data.CrmDatabase
5 +import fr.ebii.card2vcf.data.EntrepriseEntity
6 +import fr.ebii.card2vcf.data.InteractionEntity
7 +import fr.ebii.card2vcf.data.ProjetEntity
8 +import fr.ebii.card2vcf.data.TacheEntity
9 +import fr.ebii.card2vcf.data.WorkflowEntity
10 +import kotlinx.coroutines.Dispatchers
11 +import kotlinx.coroutines.withContext
12 +import kotlinx.serialization.Serializable
13 +import kotlinx.serialization.encodeToString
14 +
15 +data class SyncStatusResult(
16 + val pendingRemoteChanges: Int,
17 + val response: SyncStatusResponse? = null,
18 + val error: String? = null,
19 +)
20 +
21 +data class SyncResult(
22 + val success: Boolean,
23 + val serverTime: String? = null,
24 + val error: String? = null,
25 + val conflicts: List<AgendaConflict> = emptyList(),
26 +)
27 +
28 +/**
29 + * Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones.
30 + * Le pont Agenda↔Room (RDV + réservations/indispos liés à des calendriers Android) est délégué à
31 + * [AgendaSyncCoordinator] ; ce moteur orchestre l'ordre des étapes et le push (y compris le cas
32 + * particulier 409 sur les réservations).
33 + */
34 +class SyncEngine(
35 + private val api: AilianceApi,
36 + private val db: CrmDatabase,
37 + private val imageStore: fr.ebii.card2vcf.data.ContactImageStore? = null,
38 +) {
39 + private val agenda = AgendaSyncCoordinator(db)
40 +
41 + suspend fun checkStatus(ressourcesQuery: String? = null): SyncStatusResult = withContext(Dispatchers.IO) {
42 + when (val result = api.syncStatus(watermark(), ressourcesQuery)) {
43 + is AilianceApiClient.ApiResult.Ok ->
44 + SyncStatusResult(pendingRemoteChanges = result.value.total, response = result.value)
45 + is AilianceApiClient.ApiResult.Err ->
46 + SyncStatusResult(pendingRemoteChanges = 0, error = result.message)
47 + }
48 + }
49 +
50 + /**
51 + * [bindings]/[bridge] vides ou `null` : chemin CRM v1 inchangé (pas de pont Agenda, `ressources=` omis).
52 + */
53 + suspend fun syncNow(
54 + bindings: List<CalendarBinding> = emptyList(),
55 + bridge: CalendarBridgeApi? = null,
56 + ): SyncResult = withContext(Dispatchers.IO) {
57 + val agendaEnabled = bridge != null && bindings.isNotEmpty()
58 + if (agendaEnabled) agenda.agendaToRoom(bindings, bridge!!)
59 +
60 + val conflicts = pushOps()
61 + val ressourcesQuery = if (agendaEnabled) agenda.ressourcesQuery(bindings) else null
62 +
63 + when (val result = api.syncPull(watermark(), ressourcesQuery)) {
64 + is AilianceApiClient.ApiResult.Ok -> {
65 + applyPull(result.value)
66 + if (agendaEnabled) agenda.roomToAgenda(bindings, bridge!!)
67 + setWatermark(result.value.serverTime)
68 + SyncResult(success = true, serverTime = result.value.serverTime, conflicts = conflicts)
69 + }
70 + is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message, conflicts = conflicts)
71 + }
72 + }
73 +
74 + /**
75 + * Nombre d'éléments locaux non encore poussés (bandeau « calendrier local en avance »).
76 + * Avec [bindings]/[bridge] : ajoute les events Agenda card2vcf-tagués sans entité Room
77 + * (orphelins pas encore nettoyés côté Agenda, voir [AgendaSyncCoordinator.orphanEventCount]).
78 + */
79 + suspend fun localAheadCount(
80 + bindings: List<CalendarBinding> = emptyList(),
81 + bridge: CalendarBridgeApi? = null,
82 + ): Int = withContext(Dispatchers.IO) {
83 + val dirtyRdv = db.rdvDao().listDirty().size
84 + val dirtyReservations = db.reservationDao().listDirty().size
85 + val pendingAgendaOps = db.syncOpDao().listAll().count { it.entityType == AgendaSyncCoordinator.KIND_RDV || it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION }
86 + val orphanEvents = if (bridge != null && bindings.isNotEmpty()) agenda.orphanEventCount(bindings, bridge) else 0
87 + dirtyRdv + dirtyReservations + pendingAgendaOps + orphanEvents
88 + }
89 +
90 + /** L'utilisateur renonce à sa réservation en conflit : purge entité + SyncOp + event Agenda. */
91 + suspend fun abandonLocalReservation(localId: Long, bridge: CalendarBridgeApi? = null) = withContext(Dispatchers.IO) {
92 + val reservation = db.reservationDao().getByLocalId(localId) ?: return@withContext
93 + reservation.calendarEventId?.let { bridge?.deleteEvent(it) }
94 + db.syncOpDao().listAll()
95 + .filter { it.entityType == AgendaSyncCoordinator.ENTITY_RESERVATION && it.localId == localId }
96 + .forEach { db.syncOpDao().deleteById(it.id) }
97 + db.reservationDao().deleteByLocalId(localId)
98 + }
99 +
100 + // ---- watermark ----
101 +
102 + private suspend fun watermark(): String =
103 + db.syncMetaDao().get(WATERMARK_KEY)?.value ?: DEFAULT_WATERMARK
104 +
105 + private suspend fun setWatermark(serverTime: String) {
106 + db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_KEY, value = serverTime))
107 + }
108 +
109 + // ---- push ----
110 +
111 + /** Résultat d'une op poussée : [conflict] non nul uniquement pour un 409 réservation ([ok] reste `false`). */
112 + private data class PushOutcome(val ok: Boolean, val conflict: AgendaConflict? = null)
113 +
114 + private suspend fun pushOps(): List<AgendaConflict> {
115 + val conflicts = mutableListOf<AgendaConflict>()
116 + for (op in db.syncOpDao().listAll()) {
117 + val outcome = when (op.entityType) {
118 + "contact" -> PushOutcome(pushContactOp(op))
119 + ENTITY_CONTACT_MEDIA -> PushOutcome(pushContactMediaOp(op))
120 + "entreprise" -> PushOutcome(pushEntrepriseOp(op))
121 + "projet" -> PushOutcome(pushProjetOp(op))
122 + "tache" -> PushOutcome(pushTacheOp(op))
123 + "interaction" -> PushOutcome(pushInteractionOp(op))
124 + AgendaSyncCoordinator.KIND_RDV -> PushOutcome(pushRdvOp(op))
125 + AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op)
126 + else -> PushOutcome(true)
127 + }
128 + if (outcome.ok) db.syncOpDao().deleteById(op.id)
129 + outcome.conflict?.let(conflicts::add)
130 + }
131 + return conflicts
132 + }
133 +
134 + private suspend fun pushContactOp(op: SyncOpEntity): Boolean {
135 + return when (op.op) {
136 + "create" -> {
137 + val result = api.createContact(op.payloadJson)
138 + if (result is AilianceApiClient.ApiResult.Ok) {
139 + val newServerId = parseCreatedId(result.value)
140 + if (newServerId != null && op.localId != null) {
141 + db.crmContactDao().getById(op.localId)?.let {
142 + db.crmContactDao().update(it.copy(serverId = newServerId))
143 + }
144 + if (!pushContactImages(op.localId, newServerId)) {
145 + enqueueContactMediaRetry(op.localId, newServerId)
146 + }
147 + }
148 + }
149 + result is AilianceApiClient.ApiResult.Ok
150 + }
151 + "update" -> {
152 + val serverId = op.serverId ?: return false
153 + if (!api.updateContact(serverId, op.payloadJson).isOk()) return false
154 + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id
155 + if (localId != null && !pushContactImages(localId, serverId)) {
156 + enqueueContactMediaRetry(localId, serverId)
157 + }
158 + true
159 + }
160 + "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false
161 + else -> true
162 + }
163 + }
164 +
165 + private suspend fun pushContactMediaOp(op: SyncOpEntity): Boolean {
166 + val serverId = op.serverId ?: return true
167 + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return true
168 + return pushContactImages(localId, serverId)
169 + }
170 +
171 + /**
172 + * Upload carte / photo locales vers le serveur. `true` si rien à envoyer ou tout OK.
173 + * `false` si au moins un fichier présent n'a pas pu être téléversé (retry via [ENTITY_CONTACT_MEDIA]).
174 + */
175 + private suspend fun pushContactImages(localId: Long, serverId: String): Boolean {
176 + val contact = db.crmContactDao().getById(localId) ?: return true
177 + var ok = true
178 + val cardPath = contact.cardImagePath?.takeIf { it.isNotBlank() }
179 + if (cardPath != null) {
180 + val file = java.io.File(cardPath)
181 + if (file.isFile) {
182 + val result = api.uploadContactCarte(serverId, file.readBytes(), "card.jpg")
183 + if (result !is AilianceApiClient.ApiResult.Ok) ok = false
184 + }
185 + }
186 + val profilePath = contact.profileImagePath?.takeIf { it.isNotBlank() }
187 + if (profilePath != null) {
188 + val file = java.io.File(profilePath)
189 + if (file.isFile) {
190 + val result = api.uploadContactPhoto(serverId, file.readBytes(), "profile.jpg")
191 + if (result !is AilianceApiClient.ApiResult.Ok) ok = false
192 + }
193 + }
194 + return ok
195 + }
196 +
197 + private suspend fun enqueueContactMediaRetry(localId: Long, serverId: String) {
198 + val already = db.syncOpDao().listAll().any {
199 + it.entityType == ENTITY_CONTACT_MEDIA && it.localId == localId && it.serverId == serverId
200 + }
201 + if (already) return
202 + db.syncOpDao().insert(
203 + SyncOpEntity(
204 + entityType = ENTITY_CONTACT_MEDIA,
205 + op = "upload",
206 + payloadJson = "{}",
207 + localId = localId,
208 + serverId = serverId,
209 + createdAt = System.currentTimeMillis(),
210 + ),
211 + )
212 + }
213 +
214 + private suspend fun pushEntrepriseOp(op: SyncOpEntity): Boolean = when (op.op) {
215 + "create" -> {
216 + val result = api.createEntreprise(op.payloadJson)
217 + if (result is AilianceApiClient.ApiResult.Ok) {
218 + val newServerId = parseCreatedId(result.value)
219 + if (newServerId != null && op.localId != null) {
220 + db.entrepriseDao().getByLocalId(op.localId)?.let {
221 + db.entrepriseDao().upsert(it.copy(serverId = newServerId))
222 + }
223 + }
224 + }
225 + result is AilianceApiClient.ApiResult.Ok
226 + }
227 + "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).isOk() } ?: false
228 + "delete" -> op.serverId?.let { api.deleteEntreprise(it).isOk() } ?: false
229 + else -> true
230 + }
231 +
232 + private suspend fun pushProjetOp(op: SyncOpEntity): Boolean = when (op.op) {
233 + "create" -> {
234 + val result = api.createProjet(op.payloadJson)
235 + if (result is AilianceApiClient.ApiResult.Ok) {
236 + val newServerId = parseCreatedId(result.value)
237 + if (newServerId != null && op.localId != null) {
238 + db.projetDao().getByLocalId(op.localId)?.let {
239 + db.projetDao().upsert(it.copy(serverId = newServerId))
240 + }
241 + }
242 + }
243 + result is AilianceApiClient.ApiResult.Ok
244 + }
245 + "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).isOk() } ?: false
246 + "delete" -> op.serverId?.let { api.deleteProjet(it).isOk() } ?: false
247 + else -> true
248 + }
249 +
250 + /** Résout le `projetServerId` d'une tâche via [op.localId] (create) ou [op.serverId] (update/delete/move). */
251 + private suspend fun resolveTacheProjetId(op: SyncOpEntity): String? {
252 + op.localId?.let { db.tacheDao().getByLocalId(it)?.let { t -> return t.projetServerId } }
253 + op.serverId?.let { db.tacheDao().getByServerId(it)?.let { t -> return t.projetServerId } }
254 + return null
255 + }
256 +
257 + private suspend fun pushTacheOp(op: SyncOpEntity): Boolean {
258 + val projetId = resolveTacheProjetId(op) ?: return false
259 + return when (op.op) {
260 + "create" -> {
261 + val result = api.createTache(projetId, op.payloadJson)
262 + if (result is AilianceApiClient.ApiResult.Ok) {
263 + val newServerId = parseCreatedId(result.value)
264 + if (newServerId != null && op.localId != null) {
265 + db.tacheDao().getByLocalId(op.localId)?.let {
266 + db.tacheDao().upsert(it.copy(serverId = newServerId))
267 + }
268 + }
269 + }
270 + result is AilianceApiClient.ApiResult.Ok
271 + }
272 + "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).isOk() } ?: false
273 + "delete" -> op.serverId?.let { api.deleteTache(projetId, it).isOk() } ?: false
274 + "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).isOk() } ?: false
275 + else -> true
276 + }
277 + }
278 +
279 + /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */
280 + private suspend fun pushInteractionOp(op: SyncOpEntity): Boolean {
281 + if (op.op != "create") return true
282 + val contactServerId = op.serverId ?: return false
283 + return api.createInteraction(contactServerId, op.payloadJson).isOk()
284 + }
285 +
286 + private suspend fun pushRdvOp(op: SyncOpEntity): Boolean = when (op.op) {
287 + "create" -> {
288 + val result = api.createRdv(op.payloadJson)
289 + if (result is AilianceApiClient.ApiResult.Ok) {
290 + val newServerId = parseCreatedId(result.value)
291 + if (newServerId != null && op.localId != null) {
292 + db.rdvDao().getByLocalId(op.localId)?.let {
293 + db.rdvDao().upsert(it.copy(serverId = newServerId, dirtyLocal = false))
294 + }
295 + }
296 + }
297 + result is AilianceApiClient.ApiResult.Ok
298 + }
299 + "update" -> op.serverId?.let { serverId ->
300 + val ok = api.updateRdv(serverId, op.payloadJson).isOk()
301 + if (ok && op.localId != null) {
302 + db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(dirtyLocal = false)) }
303 + }
304 + ok
305 + } ?: false
306 + "delete" -> op.serverId?.let { api.deleteRdv(it).isOk() } ?: false
307 + else -> true
308 + }
309 +
310 + /**
311 + * Résout `(cibleType, cibleId)` via [op.localId] (create) ou [op.serverId] (update/delete), avec
312 + * repli sur `op.payloadJson` (un `delete` détecté après suppression Agenda supprime déjà l'entité
313 + * locale ; voir `AgendaSyncCoordinator.cibleRefPayload`).
314 + */
315 + private suspend fun resolveReservationCible(op: SyncOpEntity): Pair<String, String>? {
316 + op.localId?.let { db.reservationDao().getByLocalId(it)?.let { r -> return r.cibleType to r.cibleId } }
317 + op.serverId?.let { db.reservationDao().getByServerId(it)?.let { r -> return r.cibleType to r.cibleId } }
318 + return parseCibleRef(op.payloadJson)
319 + }
320 +
321 + /**
322 + * Sur 409 (chevauchement serveur) : ne pas dépiler l'op (voir [pushOps]), marquer `conflictPending`
323 + * sur l'entité locale et remonter un [AgendaConflict] pour la dialog « Annuler ma réservation ? ».
324 + */
325 + private suspend fun pushReservationOp(op: SyncOpEntity): PushOutcome {
326 + val (cibleType, cibleId) = resolveReservationCible(op) ?: return PushOutcome(false)
327 + return when (op.op) {
328 + "create" -> handleReservationPushResult(api.createReservation(cibleType, cibleId, op.payloadJson), op)
329 + "update" -> op.serverId?.let { serverId ->
330 + handleReservationPushResult(api.updateReservation(cibleType, cibleId, serverId, op.payloadJson), op)
331 + } ?: PushOutcome(false)
332 + "delete" -> op.serverId?.let { PushOutcome(api.deleteReservation(cibleType, cibleId, it).isOk()) } ?: PushOutcome(false)
333 + else -> PushOutcome(true)
334 + }
335 + }
336 +
337 + private suspend fun handleReservationPushResult(
338 + result: AilianceApiClient.ApiResult<String>,
339 + op: SyncOpEntity,
340 + ): PushOutcome = when (result) {
341 + is AilianceApiClient.ApiResult.Ok -> {
342 + if (op.localId != null) {
343 + db.reservationDao().getByLocalId(op.localId)?.let { local ->
344 + val serverId = if (op.op == "create") parseCreatedId(result.value) ?: local.serverId else local.serverId
345 + db.reservationDao().upsert(local.copy(serverId = serverId, dirtyLocal = false))
346 + }
347 + }
348 + PushOutcome(true)
349 + }
350 + is AilianceApiClient.ApiResult.Err -> {
351 + if (result.code == 409 && op.localId != null) {
352 + db.reservationDao().getByLocalId(op.localId)?.let { local ->
353 + db.reservationDao().upsert(local.copy(conflictPending = true))
354 + }
355 + PushOutcome(false, AgendaConflict(localReservationId = op.localId, message = result.message))
356 + } else {
357 + PushOutcome(false)
358 + }
359 + }
360 + }
361 +
362 + // ---- pull ----
363 +
364 + private suspend fun applyPull(pull: SyncPullResponse) {
365 + pull.contacts.forEach { applyContact(it) }
366 + pull.entreprises.forEach { applyEntreprise(it) }
367 + pull.projets.forEach { applyProjet(it) }
368 + pull.taches.forEach { applyTache(it) }
369 + pull.interactions.forEach { applyInteraction(it) }
370 + pull.workflows.forEach { applyWorkflow(it) }
371 + pull.rdv.forEach { agenda.applyRdvPull(it) }
372 + pull.reservations.forEach { agenda.applyReservationPull(it) }
373 + pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) }
374 + pull.tombstones.forEach { applyTombstone(it) }
375 + }
376 +
377 + private suspend fun applyContact(dto: ContactDto) {
378 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
379 + val local = db.crmContactDao().getByServerId(dto.id)
380 + if (local == null) {
381 + val newId = db.crmContactDao().insert(
382 + CrmContactEntity(
383 + serverId = dto.id,
384 + fullName = "${dto.prenom} ${dto.nom}".trim(),
385 + firstName = dto.prenom,
386 + lastName = dto.nom,
387 + jobTitle = dto.fonction,
388 + statut = dto.statut,
389 + etape = dto.etape,
390 + tags = dto.tags,
391 + entrepriseServerId = dto.entrepriseId,
392 + createdAt = parseIsoToEpochMs(dto.creeLe),
393 + updatedAt = remoteTs,
394 + ),
395 + )
396 + pullContactImages(newId, dto, remoteTs, localUpdatedAt = 0L)
397 + return
398 + }
399 + val remoteEntity = local.copy(
400 + fullName = "${dto.prenom} ${dto.nom}".trim(),
401 + firstName = dto.prenom,
402 + lastName = dto.nom,
403 + jobTitle = dto.fonction,
404 + statut = dto.statut,
405 + etape = dto.etape,
406 + tags = dto.tags,
407 + entrepriseServerId = dto.entrepriseId,
408 + updatedAt = remoteTs,
409 + )
410 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
411 + if (merged !== local) db.crmContactDao().update(merged)
412 + pullContactImages(local.id, dto, remoteTs, local.updatedAt)
413 + }
414 +
415 + /**
416 + * Télécharge carte/photo si présentes côté serveur et (fichier local absent
417 + * ou serveur plus récent), sauf si un push contact/média est encore en file.
418 + */
419 + private suspend fun pullContactImages(
420 + localId: Long,
421 + dto: ContactDto,
422 + remoteTs: Long,
423 + localUpdatedAt: Long,
424 + ) {
425 + val store = imageStore ?: return
426 + if (hasPendingContactPush(localId, dto.id)) return
427 + var contact = db.crmContactDao().getById(localId) ?: return
428 + val serverNewer = remoteTs > localUpdatedAt
429 +
430 + if (!dto.carteVisite.isNullOrBlank()) {
431 + val path = contact.cardImagePath
432 + val missing = path.isNullOrBlank() || !java.io.File(path).isFile
433 + if (missing || serverNewer) {
434 + when (val result = api.downloadContactCarte(dto.id)) {
435 + is AilianceApiClient.ApiResult.Ok -> {
436 + val saved = store.saveBytes(localId, "card.jpg", result.value)
437 + contact = contact.copy(cardImagePath = saved)
438 + db.crmContactDao().update(contact)
439 + }
440 + is AilianceApiClient.ApiResult.Err -> Unit
441 + }
442 + }
443 + }
444 + if (!dto.photo.isNullOrBlank()) {
445 + val path = contact.profileImagePath
446 + val missing = path.isNullOrBlank() || !java.io.File(path).isFile
447 + if (missing || serverNewer) {
448 + when (val result = api.downloadContactPhoto(dto.id)) {
449 + is AilianceApiClient.ApiResult.Ok -> {
450 + val saved = store.saveBytes(localId, "profile.jpg", result.value)
451 + contact = contact.copy(profileImagePath = saved)
452 + db.crmContactDao().update(contact)
453 + }
454 + is AilianceApiClient.ApiResult.Err -> Unit
455 + }
456 + }
457 + }
458 + }
459 +
460 + private suspend fun hasPendingContactPush(localId: Long, serverId: String): Boolean =
461 + db.syncOpDao().listAll().any { op ->
462 + when (op.entityType) {
463 + "contact", ENTITY_CONTACT_MEDIA ->
464 + (op.localId == localId || op.serverId == serverId) && op.op != "delete"
465 + else -> false
466 + }
467 + }
468 +
469 + private suspend fun applyEntreprise(dto: EntrepriseDto) {
470 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
471 + val local = db.entrepriseDao().getByServerId(dto.id)
472 + if (local == null) {
473 + db.entrepriseDao().upsert(
474 + EntrepriseEntity(
475 + serverId = dto.id,
476 + nom = dto.nom,
477 + secteur = dto.secteur,
478 + creePar = dto.creePar,
479 + createdAt = parseIsoToEpochMs(dto.creeLe),
480 + updatedAt = remoteTs,
481 + ),
482 + )
483 + return
484 + }
485 + val remoteEntity = local.copy(nom = dto.nom, secteur = dto.secteur, creePar = dto.creePar, updatedAt = remoteTs)
486 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
487 + if (merged !== local) db.entrepriseDao().upsert(merged)
488 + }
489 +
490 + private suspend fun applyProjet(dto: ProjetDto) {
491 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
492 + val local = db.projetDao().getByServerId(dto.id)
493 + val membresJson = syncJson.encodeToString(dto.membres)
494 + if (local == null) {
495 + db.projetDao().upsert(
496 + ProjetEntity(
497 + serverId = dto.id,
498 + nom = dto.nom,
499 + description = dto.description,
500 + workflowServerId = dto.workflowId,
501 + membresJson = membresJson,
502 + creePar = dto.creePar,
503 + createdAt = parseIsoToEpochMs(dto.creeLe),
504 + updatedAt = remoteTs,
505 + ),
506 + )
507 + return
508 + }
509 + val remoteEntity = local.copy(
510 + nom = dto.nom,
511 + description = dto.description,
512 + workflowServerId = dto.workflowId,
513 + membresJson = membresJson,
514 + creePar = dto.creePar,
515 + updatedAt = remoteTs,
516 + )
517 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
518 + if (merged !== local) db.projetDao().upsert(merged)
519 + }
520 +
521 + private suspend fun applyTache(dto: TacheSyncDto) {
522 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
523 + val local = db.tacheDao().getByServerId(dto.id)
524 + val projetLocalId = db.projetDao().getByServerId(dto.projetId)?.localId
525 + if (local == null) {
526 + db.tacheDao().upsert(
527 + TacheEntity(
528 + serverId = dto.id,
529 + projetLocalId = projetLocalId,
530 + projetServerId = dto.projetId,
531 + titre = dto.titre,
532 + description = dto.description,
533 + colonneId = dto.colonneId,
534 + ordre = dto.ordre,
535 + assigneA = dto.assigneA,
536 + auteur = dto.auteur,
537 + createdAt = parseIsoToEpochMs(dto.creeLe),
538 + updatedAt = remoteTs,
539 + ),
540 + )
541 + return
542 + }
543 + val remoteEntity = local.copy(
544 + projetLocalId = projetLocalId ?: local.projetLocalId,
545 + projetServerId = dto.projetId,
546 + titre = dto.titre,
547 + description = dto.description,
548 + colonneId = dto.colonneId,
549 + ordre = dto.ordre,
550 + assigneA = dto.assigneA,
551 + auteur = dto.auteur,
552 + updatedAt = remoteTs,
553 + )
554 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
555 + if (merged !== local) db.tacheDao().upsert(merged)
556 + }
557 +
558 + private suspend fun applyInteraction(dto: InteractionDto) {
559 + val existing = db.interactionDao().listByContactServerId(dto.contactId).mapNotNull { it.serverId }.toSet()
560 + if (!LwwMerger.shouldInsertInteraction(existing, dto.id)) return
561 + db.interactionDao().upsert(
562 + InteractionEntity(
563 + serverId = dto.id,
564 + contactServerId = dto.contactId,
565 + type = dto.typeInteraction,
566 + sujet = dto.sujet,
567 + description = dto.description,
568 + creePar = dto.creePar,
569 + createdAt = parseIsoToEpochMs(dto.creeLe),
570 + updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) },
571 + ),
572 + )
573 + }
574 +
575 + private suspend fun applyWorkflow(dto: WorkflowDto) {
576 + db.workflowDao().upsert(
577 + WorkflowEntity(
578 + serverId = dto.id,
579 + nom = dto.nom,
580 + description = dto.description,
581 + colonnesJson = syncJson.encodeToString(dto.colonnes),
582 + creePar = dto.creePar,
583 + createdAt = parseIsoToEpochMs(dto.creeLe),
584 + ),
585 + )
586 + }
587 +
588 + private suspend fun applyTombstone(dto: TombstoneDto) {
589 + when (dto.entityType) {
590 + "contact" -> db.crmContactDao().deleteByServerId(dto.id)
591 + "entreprise" -> db.entrepriseDao().deleteByServerId(dto.id)
592 + "projet" -> {
593 + db.tacheDao().listByProjetServerId(dto.id).mapNotNull { it.serverId }
594 + .forEach { db.tacheDao().deleteByServerId(it) }
595 + db.projetDao().deleteByServerId(dto.id)
596 + }
597 + "tache" -> db.tacheDao().deleteByServerId(dto.id)
598 + "interaction" -> db.interactionDao().deleteByServerId(dto.id)
599 + else -> agenda.applyAgendaTombstone(dto)
600 + }
601 + }
602 +
603 + private companion object {
604 + const val WATERMARK_KEY = "watermark"
605 + const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z"
606 + const val ENTITY_CONTACT_MEDIA = "contact_media"
607 +
608 + fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
609 +
610 + @Serializable
611 + data class CreatedIdDto(val id: String? = null)
612 +
613 + fun parseCreatedId(body: String): String? =
614 + try {
615 + syncJson.decodeFromString(CreatedIdDto.serializer(), body).id
616 + } catch (_: Exception) {
617 + null
618 + }
619 +
620 + fun parseCibleRef(payloadJson: String): Pair<String, String>? =
621 + try {
622 + val ref = syncJson.decodeFromString(CibleRessourceDto.serializer(), payloadJson)
623 + ref.type to ref.id
624 + } catch (_: Exception) {
625 + null
626 + }
627 + }
628 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncMetaDao.kt
+15 -0
@@ -0,0 +1,15 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface SyncMetaDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: SyncMetaEntity)
12 +
13 + @Query("SELECT * FROM sync_meta WHERE key = :key")
14 + suspend fun get(key: String): SyncMetaEntity?
15 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncMetaEntity.kt
+10 -0
@@ -0,0 +1,10 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "sync_meta")
7 +data class SyncMetaEntity(
8 + @PrimaryKey val key: String,
9 + val value: String,
10 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+313 -0
@@ -0,0 +1,313 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import kotlinx.serialization.ExperimentalSerializationApi
4 +import kotlinx.serialization.SerialName
5 +import kotlinx.serialization.Serializable
6 +import kotlinx.serialization.json.Json
7 +import kotlinx.serialization.json.JsonNamingStrategy
8 +
9 +/** JSON config partagée : snake_case serveur Projectiaon, champs inconnus ignorés. */
10 +@OptIn(ExperimentalSerializationApi::class)
11 +val syncJson: Json = Json {
12 + ignoreUnknownKeys = true
13 + namingStrategy = JsonNamingStrategy.SnakeCase
14 + encodeDefaults = true
15 +}
16 +
17 +// ---- Auth clé ----
18 +
19 +@Serializable
20 +data class AuthCleRequest(
21 + val nom: String,
22 + val motDePasse: String,
23 +)
24 +
25 +@Serializable
26 +data class AuthCleResponse(
27 + val nom: String,
28 + val cleChiffree: String,
29 + val sel: String,
30 + val kdf: String,
31 + val cipher: String,
32 +)
33 +
34 +// ---- Sync status / pull ----
35 +
36 +@Serializable
37 +data class SyncChanges(
38 + val contacts: Int = 0,
39 + val entreprises: Int = 0,
40 + val projets: Int = 0,
41 + val taches: Int = 0,
42 + val interactions: Int = 0,
43 + val rdv: Int = 0,
44 + val reservations: Int = 0,
45 + val indisponibilites: Int = 0,
46 + val tombstones: Int = 0,
47 +)
48 +
49 +@Serializable
50 +data class SyncStatusResponse(
51 + val serverTime: String,
52 + val changes: SyncChanges,
53 + val total: Int,
54 +)
55 +
56 +@Serializable
57 +data class SyncPullResponse(
58 + val serverTime: String,
59 + val contacts: List<ContactDto> = emptyList(),
60 + val entreprises: List<EntrepriseDto> = emptyList(),
61 + val projets: List<ProjetDto> = emptyList(),
62 + val taches: List<TacheSyncDto> = emptyList(),
63 + val interactions: List<InteractionDto> = emptyList(),
64 + val rdv: List<RendezVousDto> = emptyList(),
65 + val reservations: List<ReservationDto> = emptyList(),
66 + val indisponibilites: List<IndisponibiliteDto> = emptyList(),
67 + val tombstones: List<TombstoneDto> = emptyList(),
68 + val workflows: List<WorkflowDto> = emptyList(),
69 +)
70 +
71 +@Serializable
72 +data class TombstoneDto(
73 + @SerialName("type") val entityType: String,
74 + val id: String,
75 + val projetId: String? = null,
76 + val cibleType: String? = null,
77 + val cibleId: String? = null,
78 + val utilisateur: String? = null,
79 + val supprimeLe: String,
80 +)
81 +
82 +// ---- Entités sync (champs minimaux pour LWW / merge) ----
83 +
84 +@Serializable
85 +data class ContactDto(
86 + val id: String,
87 + val prenom: String = "",
88 + val nom: String = "",
89 + val entrepriseId: String? = null,
90 + val fonction: String = "",
91 + val statut: String = "prospect",
92 + val etape: String = "nouveau",
93 + val tags: List<String> = emptyList(),
94 + val creePar: String = "",
95 + val creeLe: String,
96 + val misAJourLe: String? = null,
97 + /** Nom de fichier serveur (`carte.jpg`), pas les octets. */
98 + val carteVisite: String? = null,
99 + /** Nom de fichier serveur (`photo.jpg`), pas les octets. */
100 + val photo: String? = null,
101 +)
102 +
103 +@Serializable
104 +data class EntrepriseDto(
105 + val id: String,
106 + val nom: String,
107 + val secteur: String = "",
108 + val creePar: String = "",
109 + val creeLe: String,
110 + val misAJourLe: String? = null,
111 +)
112 +
113 +@Serializable
114 +data class MembreProjetDto(
115 + val utilisateur: String,
116 + val niveau: String = "lecteur",
117 +)
118 +
119 +@Serializable
120 +data class ProjetDto(
121 + val id: String,
122 + val nom: String,
123 + val description: String = "",
124 + val workflowId: String,
125 + val creePar: String = "",
126 + val creeLe: String,
127 + val membres: List<MembreProjetDto> = emptyList(),
128 + val misAJourLe: String? = null,
129 +)
130 +
131 +/** Tâche aplatie : `projet_id` + champs Tache au même niveau (miroir Rust `TacheSync`). */
132 +@Serializable
133 +data class TacheSyncDto(
134 + val projetId: String,
135 + val id: String,
136 + val titre: String,
137 + val description: String = "",
138 + val colonneId: String,
139 + val ordre: Int = 0,
140 + val auteur: String = "",
141 + val creeLe: String,
142 + val assigneA: String? = null,
143 + val misAJourLe: String? = null,
144 +)
145 +
146 +@Serializable
147 +data class InteractionDto(
148 + val id: String,
149 + val contactId: String,
150 + val typeInteraction: String = "note",
151 + val sujet: String = "",
152 + val description: String = "",
153 + val creePar: String = "",
154 + val creeLe: String,
155 + val misAJourLe: String? = null,
156 +)
157 +
158 +@Serializable
159 +data class ColonneDto(
160 + val id: String,
161 + val nom: String,
162 + val ordre: Int = 0,
163 +)
164 +
165 +/** Cible d'une réservation/indisponibilité (miroir enum Rust `CibleRessource`, tag `type` + `id`). */
166 +@Serializable
167 +data class CibleRessourceDto(
168 + val type: String,
169 + val id: String,
170 +)
171 +
172 +@Serializable
173 +data class RendezVousDto(
174 + val id: String,
175 + val titre: String = "",
176 + val description: String = "",
177 + val utilisateur: String = "",
178 + val contactIds: List<String> = emptyList(),
179 + val lieu: String = "",
180 + val debut: String,
181 + val fin: String,
182 + val projetId: String? = null,
183 + val creePar: String = "",
184 + val creeLe: String,
185 + val misAJourLe: String? = null,
186 +)
187 +
188 +@Serializable
189 +data class ReservationDto(
190 + val id: String,
191 + val cible: CibleRessourceDto,
192 + val debut: String,
193 + val fin: String,
194 + val motif: String = "",
195 + val projetId: String? = null,
196 + val rdvId: String? = null,
197 + val statut: String = "active",
198 + val reservePar: String = "",
199 + val creeLe: String,
200 + val misAJourLe: String? = null,
201 +)
202 +
203 +@Serializable
204 +data class IndisponibiliteDto(
205 + val id: String,
206 + val cible: CibleRessourceDto,
207 + val nature: String,
208 + val debut: String,
209 + val fin: String,
210 + val commentaire: String = "",
211 + val creePar: String = "",
212 + val creeLe: String,
213 + val misAJourLe: String? = null,
214 +)
215 +
216 +/** Ressource catalogue allégée (salle/matériel/véhicule) : champs communs uniquement. */
217 +@Serializable
218 +data class RessourceItemDto(
219 + val id: String,
220 + val nom: String,
221 + val description: String = "",
222 + val lieu: String = "",
223 + val actif: Boolean = true,
224 +)
225 +
226 +@Serializable
227 +data class RessourcesCatalogueDto(
228 + val salles: List<RessourceItemDto> = emptyList(),
229 + val materiels: List<RessourceItemDto> = emptyList(),
230 + val vehicules: List<RessourceItemDto> = emptyList(),
231 +)
232 +
233 +@Serializable
234 +data class WorkflowDto(
235 + val id: String,
236 + val nom: String,
237 + val description: String = "",
238 + val colonnes: List<ColonneDto> = emptyList(),
239 + val creePar: String = "",
240 + val creeLe: String,
241 +)
242 +
243 +// ---- Requêtes de mutation locales (payload des SyncOp, miroir des `*Input` serveur) ----
244 +
245 +@Serializable
246 +data class CreateTacheRequest(
247 + val titre: String,
248 + val description: String = "",
249 + val colonneId: String = "",
250 + val assigneA: String? = null,
251 +)
252 +
253 +@Serializable
254 +data class UpdateTacheRequest(
255 + val titre: String,
256 + val description: String = "",
257 + val assigneA: String? = null,
258 + @SerialName("assigne_a_pose") val assigneAPose: Boolean = true,
259 +)
260 +
261 +@Serializable
262 +data class MoveTacheRequest(
263 + val colonneId: String,
264 +)
265 +
266 +@Serializable
267 +data class CreateInteractionRequest(
268 + val sujet: String,
269 + val description: String = "",
270 +)
271 +
272 +@Serializable
273 +data class ContactValeurDto(
274 + val valeur: String,
275 + val categorie: String = "pro",
276 +)
277 +
278 +/** Payload create/update contact (miroir serveur `ContactInput`). */
279 +@Serializable
280 +data class CreateContactRequest(
281 + val prenom: String = "",
282 + val nom: String = "",
283 + val entrepriseId: String? = null,
284 + val fonction: String = "",
285 + val emails: List<ContactValeurDto> = emptyList(),
286 + val telephones: List<ContactValeurDto> = emptyList(),
287 + val notes: String = "",
288 + val statut: String = "prospect",
289 + val etape: String = "nouveau",
290 + val tags: List<String> = emptyList(),
291 +)
292 +
293 +/** Payload create/update RDV (miroir serveur, body `/api/rdv`). */
294 +@Serializable
295 +data class RdvUpsertRequest(
296 + val titre: String = "",
297 + val description: String = "",
298 + val lieu: String = "",
299 + val debut: String,
300 + val fin: String,
301 + val contactIds: List<String> = emptyList(),
302 + val projetId: String? = null,
303 +)
304 +
305 +/** Payload create/update réservation (miroir serveur, body `/api/ressources/:genre/:id/reservations`). */
306 +@Serializable
307 +data class ReservationUpsertRequest(
308 + val debut: String,
309 + val fin: String,
310 + val motif: String = "",
311 + val projetId: String? = null,
312 + val rdvId: String? = null,
313 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpDao.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface SyncOpDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun insert(entity: SyncOpEntity): Long
12 +
13 + @Query("SELECT * FROM sync_ops ORDER BY createdAt ASC")
14 + suspend fun listAll(): List<SyncOpEntity>
15 +
16 + @Query("SELECT * FROM sync_ops WHERE id = :id")
17 + suspend fun getById(id: Long): SyncOpEntity?
18 +
19 + @Query("DELETE FROM sync_ops WHERE id = :id")
20 + suspend fun deleteById(id: Long)
21 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpEntity.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "sync_ops")
7 +data class SyncOpEntity(
8 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
9 + val entityType: String,
10 + val op: String,
11 + val payloadJson: String = "{}",
12 + val localId: Long? = null,
13 + val serverId: String? = null,
14 + val createdAt: Long = 0L,
15 + val attempts: Int = 0,
16 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncTimeUtils.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** ISO-8601 UTC → epoch ms, tolérant aux valeurs absentes/invalides (miroir horodatages serveur `mis_a_jour_le`). */
4 +internal fun parseIsoToEpochMs(iso: String?): Long =
5 + if (iso == null) {
6 + 0L
7 + } else {
8 + try {
9 + java.time.Instant.parse(iso).toEpochMilli()
10 + } catch (_: Exception) {
11 + 0L
12 + }
13 + }
14 +
15 +/** epoch ms → ISO-8601 UTC, pour les payloads poussés au serveur (`debut`/`fin`). */
16 +internal fun epochMsToIso(epochMs: Long): String = java.time.Instant.ofEpochMilli(epochMs).toString()
A android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftFields.kt
+73 -0
@@ -0,0 +1,73 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.fillMaxWidth
6 +import androidx.compose.material3.OutlinedTextField
7 +import androidx.compose.material3.Text
8 +import androidx.compose.runtime.Composable
9 +import androidx.compose.ui.Modifier
10 +import androidx.compose.ui.res.stringResource
11 +import androidx.compose.ui.unit.dp
12 +import fr.ebii.card2vcf.R
13 +import fr.ebii.card2vcf.contact.ContactCard
14 +
15 +@Composable
16 +fun ContactDraftFields(
17 + card: ContactCard,
18 + onChange: (ContactCard) -> Unit,
19 + modifier: Modifier = Modifier,
20 +) {
21 + // Pas de verticalScroll ici : le parent ContactDraftScreen scrolle déjà.
22 + Column(
23 + modifier,
24 + verticalArrangement = Arrangement.spacedBy(10.dp),
25 + ) {
26 + DraftField(stringResource(R.string.scan_champ_nom), card.fullName.orEmpty()) {
27 + onChange(card.copy(fullName = it.ifBlank { null }))
28 + }
29 + DraftField(stringResource(R.string.scan_champ_prenom), card.firstName.orEmpty()) {
30 + onChange(card.copy(firstName = it.ifBlank { null }))
31 + }
32 + DraftField(stringResource(R.string.scan_champ_nom_famille), card.lastName.orEmpty()) {
33 + onChange(card.copy(lastName = it.ifBlank { null }))
34 + }
35 + DraftField(stringResource(R.string.scan_champ_societe), card.company.orEmpty()) {
36 + onChange(card.copy(company = it.ifBlank { null }))
37 + }
38 + DraftField(stringResource(R.string.scan_champ_poste), card.jobTitle.orEmpty()) {
39 + onChange(card.copy(jobTitle = it.ifBlank { null }))
40 + }
41 + DraftField(stringResource(R.string.scan_champ_tel), card.phones.joinToString(", ")) {
42 + onChange(card.copy(phones = it.split(',', ';').map { p -> p.trim() }.filter { p -> p.isNotEmpty() }))
43 + }
44 + DraftField(stringResource(R.string.scan_champ_email), card.emails.joinToString(", ")) {
45 + onChange(card.copy(emails = it.split(',', ';').map { e -> e.trim() }.filter { e -> e.isNotEmpty() }))
46 + }
47 + DraftField(stringResource(R.string.scan_champ_site), card.website.orEmpty()) {
48 + onChange(card.copy(website = it.ifBlank { null }))
49 + }
50 + DraftField(stringResource(R.string.scan_champ_adresse), card.address.orEmpty()) {
51 + onChange(card.copy(address = it.ifBlank { null }))
52 + }
53 + DraftField(stringResource(R.string.scan_champ_note), card.note.orEmpty(), minLines = 2) {
54 + onChange(card.copy(note = it.ifBlank { null }))
55 + }
56 + }
57 +}
58 +
59 +@Composable
60 +private fun DraftField(
61 + label: String,
62 + value: String,
63 + minLines: Int = 1,
64 + onChange: (String) -> Unit,
65 +) {
66 + OutlinedTextField(
67 + value = value,
68 + onValueChange = onChange,
69 + label = { Text(label) },
70 + minLines = minLines,
71 + modifier = Modifier.fillMaxWidth(),
72 + )
73 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftScreen.kt
+120 -0
@@ -0,0 +1,120 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.border
5 +import androidx.compose.foundation.layout.Arrangement
6 +import androidx.compose.foundation.layout.Box
7 +import androidx.compose.foundation.layout.Column
8 +import androidx.compose.foundation.layout.fillMaxSize
9 +import androidx.compose.foundation.layout.fillMaxWidth
10 +import androidx.compose.foundation.layout.padding
11 +import androidx.compose.foundation.rememberScrollState
12 +import androidx.compose.foundation.shape.RoundedCornerShape
13 +import androidx.compose.foundation.text.selection.SelectionContainer
14 +import androidx.compose.foundation.verticalScroll
15 +import androidx.compose.material3.Button
16 +import androidx.compose.material3.ButtonDefaults
17 +import androidx.compose.material3.CircularProgressIndicator
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.material3.OutlinedButton
20 +import androidx.compose.material3.Text
21 +import androidx.compose.runtime.Composable
22 +import androidx.compose.ui.Alignment
23 +import androidx.compose.ui.Modifier
24 +import androidx.compose.ui.res.stringResource
25 +import androidx.compose.ui.text.font.FontFamily
26 +import androidx.compose.ui.unit.dp
27 +import fr.ebii.card2vcf.R
28 +import fr.ebii.card2vcf.ui.theme.Bordure
29 +import fr.ebii.card2vcf.ui.theme.Encre
30 +import fr.ebii.card2vcf.ui.theme.Fond
31 +import fr.ebii.card2vcf.ui.theme.Ink
32 +import fr.ebii.card2vcf.ui.theme.OnPrimary
33 +import fr.ebii.card2vcf.ui.theme.Surface
34 +import fr.ebii.card2vcf.ui.theme.TexteFaible
35 +
36 +private val Square = RoundedCornerShape(0.dp)
37 +
38 +@Composable
39 +fun ContactDraftScreen(
40 + draft: ContactDraftUi,
41 + onCardChange: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
42 + onSaveToCarnet: () -> Unit,
43 + onCreateContact: () -> Unit,
44 + onExportVcf: () -> Unit,
45 + onRetry: () -> Unit,
46 + modifier: Modifier = Modifier,
47 + isEditing: Boolean = false,
48 +) {
49 + Column(
50 + modifier.fillMaxSize().background(Fond).padding(horizontal = 18.dp, vertical = 16.dp),
51 + verticalArrangement = Arrangement.spacedBy(14.dp),
52 + ) {
53 + Text(stringResource(R.string.scan_brouillon_titre), style = MaterialTheme.typography.titleLarge, color = Encre)
54 + Text(stringResource(R.string.scan_brouillon_sous_titre), color = TexteFaible)
55 + Column(
56 + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
57 + verticalArrangement = Arrangement.spacedBy(14.dp),
58 + ) {
59 + ContactDraftFields(card = draft.card, onChange = onCardChange)
60 + Text(stringResource(R.string.scan_ocr_brut), color = Encre)
61 + Box(
62 + Modifier.fillMaxWidth().background(Surface)
63 + .border(1.dp, Bordure).padding(12.dp),
64 + ) {
65 + SelectionContainer {
66 + Text(
67 + draft.rawOcrText,
68 + fontFamily = FontFamily.Monospace,
69 + color = TexteFaible,
70 + )
71 + }
72 + }
73 + }
74 + Button(
75 + onClick = onSaveToCarnet,
76 + modifier = Modifier.fillMaxWidth(),
77 + shape = Square,
78 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
79 + ) {
80 + Text(
81 + stringResource(
82 + if (isEditing) R.string.edit_mettre_a_jour else R.string.scan_enregistrer_carnet,
83 + ),
84 + )
85 + }
86 + OutlinedButton(
87 + onClick = onCreateContact,
88 + modifier = Modifier.fillMaxWidth(),
89 + shape = Square,
90 + ) {
91 + Text(stringResource(R.string.scan_creer_contact))
92 + }
93 + OutlinedButton(
94 + onClick = onExportVcf,
95 + modifier = Modifier.fillMaxWidth(),
96 + shape = Square,
97 + ) {
98 + Text(stringResource(R.string.scan_exporter_vcf))
99 + }
100 + OutlinedButton(
101 + onClick = onRetry,
102 + modifier = Modifier.fillMaxWidth(),
103 + shape = Square,
104 + ) {
105 + Text(stringResource(R.string.scan_reprendre))
106 + }
107 + }
108 +}
109 +
110 +@Composable
111 +fun ScanProgress(messageRes: Int) {
112 + Column(
113 + Modifier.fillMaxSize().background(Fond),
114 + verticalArrangement = Arrangement.Center,
115 + horizontalAlignment = Alignment.CenterHorizontally,
116 + ) {
117 + CircularProgressIndicator()
118 + Text(stringResource(messageRes), modifier = Modifier.padding(top = 16.dp), color = TexteFaible)
119 + }
120 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteScreen.kt
+217 -0
@@ -0,0 +1,217 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import android.Manifest
4 +import android.content.pm.PackageManager
5 +import android.graphics.Bitmap
6 +import androidx.activity.compose.rememberLauncherForActivityResult
7 +import androidx.activity.result.contract.ActivityResultContracts
8 +import androidx.camera.core.CameraSelector
9 +import androidx.camera.core.ImageCapture
10 +import androidx.camera.core.ImageCaptureException
11 +import androidx.camera.core.ImageProxy
12 +import androidx.camera.core.Preview
13 +import androidx.camera.lifecycle.ProcessCameraProvider
14 +import androidx.camera.view.PreviewView
15 +import fr.ebii.card2vcf.scan.ImageOrientation
16 +import androidx.compose.foundation.background
17 +import androidx.compose.foundation.layout.Arrangement
18 +import androidx.compose.foundation.layout.Box
19 +import androidx.compose.foundation.layout.Column
20 +import androidx.compose.foundation.layout.fillMaxSize
21 +import androidx.compose.foundation.layout.fillMaxWidth
22 +import androidx.compose.foundation.layout.padding
23 +import androidx.compose.material.icons.Icons
24 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
25 +import androidx.compose.material3.Button
26 +import androidx.compose.material3.Icon
27 +import androidx.compose.material3.IconButton
28 +import androidx.compose.material3.MaterialTheme
29 +import androidx.compose.material3.OutlinedButton
30 +import androidx.compose.material3.Text
31 +import androidx.compose.runtime.Composable
32 +import androidx.compose.runtime.LaunchedEffect
33 +import androidx.compose.runtime.collectAsState
34 +import androidx.compose.runtime.getValue
35 +import androidx.compose.runtime.mutableStateOf
36 +import androidx.compose.runtime.remember
37 +import androidx.compose.runtime.setValue
38 +import androidx.compose.ui.Alignment
39 +import androidx.compose.ui.Modifier
40 +import androidx.compose.ui.platform.LocalContext
41 +import androidx.compose.ui.res.stringResource
42 +import androidx.compose.ui.unit.dp
43 +import androidx.compose.ui.viewinterop.AndroidView
44 +import androidx.core.content.ContextCompat
45 +import androidx.lifecycle.compose.LocalLifecycleOwner
46 +import fr.ebii.card2vcf.R
47 +import fr.ebii.card2vcf.ui.theme.Encre
48 +import fr.ebii.card2vcf.ui.theme.Fond
49 +import fr.ebii.card2vcf.ui.theme.Surface
50 +import fr.ebii.card2vcf.ui.theme.TexteFaible
51 +import java.util.concurrent.Executors
52 +
53 +@Composable
54 +fun ScanCarteScreen(
55 + viewModel: ScanCarteViewModel,
56 + onBack: () -> Unit,
57 + onCreateContact: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
58 + onExportVcf: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
59 + onContactUpdated: (contactId: Long) -> Unit,
60 + modifier: Modifier = Modifier,
61 +) {
62 + val state by viewModel.state.collectAsState()
63 + val saveDoneId by viewModel.saveDoneId.collectAsState()
64 + LaunchedEffect(saveDoneId) {
65 + val id = saveDoneId ?: return@LaunchedEffect
66 + viewModel.consumeSaveDone()
67 + onContactUpdated(id)
68 + }
69 + when (val s = state) {
70 + is ScanUiState.DraftReady -> ContactDraftScreen(
71 + draft = s.draft,
72 + onCardChange = viewModel::updateDraft,
73 + onSaveToCarnet = viewModel::saveToCarnet,
74 + onCreateContact = { onCreateContact(s.draft.card) },
75 + onExportVcf = { onExportVcf(s.draft.card) },
76 + onRetry = viewModel::reset,
77 + isEditing = viewModel.isEditing,
78 + modifier = modifier,
79 + )
80 + ScanUiState.Scanning -> ScanProgress(R.string.scan_en_cours)
81 + ScanUiState.OcrRunning -> ScanProgress(R.string.scan_ocr_en_cours)
82 + ScanUiState.Structuring -> ScanProgress(R.string.scan_structuration)
83 + is ScanUiState.Error -> ScanErrorScreen(s.message, onRetry = viewModel::reset, onBack = onBack)
84 + ScanUiState.Capturing, ScanUiState.Idle -> ScanCaptureScreen(viewModel, onBack, modifier)
85 + }
86 +}
87 +
88 +@Composable
89 +private fun ScanCaptureScreen(
90 + viewModel: ScanCarteViewModel,
91 + onBack: () -> Unit,
92 + modifier: Modifier = Modifier,
93 +) {
94 + val context = LocalContext.current
95 + val lifecycleOwner = LocalLifecycleOwner.current
96 + var hasCamera by remember {
97 + mutableStateOf(
98 + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
99 + )
100 + }
101 + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
102 + hasCamera = it
103 + }
104 + LaunchedEffect(Unit) {
105 + if (!hasCamera) permissionLauncher.launch(Manifest.permission.CAMERA)
106 + }
107 +
108 + val imageCapture = remember {
109 + ImageCapture.Builder()
110 + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
111 + .setJpegQuality(95)
112 + .build()
113 + }
114 + val executor = remember { Executors.newSingleThreadExecutor() }
115 +
116 + Column(modifier.fillMaxSize().background(Fond)) {
117 + RowTop(onBack)
118 + Text(
119 + stringResource(R.string.scan_instructions),
120 + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
121 + color = TexteFaible,
122 + )
123 + if (hasCamera) {
124 + Box(Modifier.weight(1f).fillMaxWidth()) {
125 + AndroidView(
126 + factory = { ctx ->
127 + PreviewView(ctx).also { previewView ->
128 + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
129 + cameraProviderFuture.addListener({
130 + val provider = cameraProviderFuture.get()
131 + val preview = Preview.Builder().build().also {
132 + it.surfaceProvider = previewView.surfaceProvider
133 + }
134 + provider.unbindAll()
135 + provider.bindToLifecycle(
136 + lifecycleOwner,
137 + CameraSelector.DEFAULT_BACK_CAMERA,
138 + preview,
139 + imageCapture,
140 + )
141 + }, ContextCompat.getMainExecutor(ctx))
142 + }
143 + },
144 + modifier = Modifier.fillMaxSize(),
145 + )
146 + }
147 + Button(
148 + onClick = {
149 + viewModel.onCaptureReady()
150 + imageCapture.takePicture(executor, object : ImageCapture.OnImageCapturedCallback() {
151 + override fun onCaptureSuccess(image: ImageProxy) {
152 + val bmp = imageProxyToBitmap(image)
153 + image.close()
154 + if (bmp != null) {
155 + viewModel.processCapture(bmp)
156 + } else {
157 + viewModel.onCaptureDecodeFailed()
158 + }
159 + }
160 + override fun onError(exception: ImageCaptureException) {
161 + viewModel.onCaptureDecodeFailed()
162 + }
163 + })
164 + },
165 + modifier = Modifier.fillMaxWidth().padding(18.dp),
166 + ) { Text(stringResource(R.string.scan_capturer)) }
167 + } else {
168 + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
169 + OutlinedButton(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
170 + Text(stringResource(R.string.scan_autoriser_camera))
171 + }
172 + }
173 + }
174 + }
175 +}
176 +
177 +@Composable
178 +private fun ScanErrorScreen(message: String, onRetry: () -> Unit, onBack: () -> Unit) {
179 + Column(
180 + Modifier.fillMaxSize().background(Fond).padding(18.dp),
181 + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
182 + horizontalAlignment = Alignment.CenterHorizontally,
183 + ) {
184 + Text(message, color = Encre)
185 + Button(onClick = onRetry) { Text(stringResource(R.string.scan_reprendre)) }
186 + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.scan_retour)) }
187 + }
188 +}
189 +
190 +@Composable
191 +private fun RowTop(onBack: () -> Unit) {
192 + Box(
193 + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp),
194 + ) {
195 + IconButton(onClick = onBack) {
196 + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.scan_retour))
197 + }
198 + Text(
199 + stringResource(R.string.scan_titre),
200 + style = MaterialTheme.typography.titleMedium,
201 + modifier = Modifier.align(Alignment.Center),
202 + color = Encre,
203 + )
204 + }
205 +}
206 +
207 +private fun imageProxyToBitmap(image: ImageProxy): Bitmap? {
208 + val plane = image.planes.firstOrNull() ?: return null
209 + val buffer = plane.buffer
210 + val bytes = ByteArray(buffer.remaining())
211 + buffer.get(bytes)
212 + // EXIF prioritaire ; sinon rotation CameraX. Jamais les deux.
213 + return ImageOrientation.fromJpegBytes(
214 + jpegBytes = bytes,
215 + cameraRotationDegrees = image.imageInfo.rotationDegrees,
216 + )?.bitmap
217 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteViewModel.kt
+155 -0
@@ -0,0 +1,155 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import android.graphics.Bitmap
4 +import android.util.Log
5 +import androidx.lifecycle.ViewModel
6 +import androidx.lifecycle.viewModelScope
7 +import fr.ebii.card2vcf.contact.ContactCard
8 +import fr.ebii.card2vcf.contact.ContactDraftMerge
9 +import fr.ebii.card2vcf.data.ContactRepository
10 +import fr.ebii.card2vcf.ocr.OcrEngine
11 +import fr.ebii.card2vcf.ocr.OcrException
12 +import fr.ebii.card2vcf.scan.ScanEngine
13 +import fr.ebii.card2vcf.scan.ScanException
14 +import kotlinx.coroutines.Dispatchers
15 +import kotlinx.coroutines.flow.MutableStateFlow
16 +import kotlinx.coroutines.flow.StateFlow
17 +import kotlinx.coroutines.flow.asStateFlow
18 +import kotlinx.coroutines.launch
19 +import kotlinx.coroutines.withContext
20 +
21 +sealed interface ScanUiState {
22 + data object Idle : ScanUiState
23 + data object Capturing : ScanUiState
24 + data object Scanning : ScanUiState
25 + data object OcrRunning : ScanUiState
26 + data object Structuring : ScanUiState
27 + data class DraftReady(val draft: ContactDraftUi) : ScanUiState
28 + data class Error(val message: String) : ScanUiState
29 +}
30 +
31 +data class ContactDraftUi(
32 + val card: ContactCard,
33 + val rawOcrText: String,
34 + val preview: Bitmap? = null,
35 +)
36 +
37 +class ScanCarteViewModel(
38 + private val scanEngine: ScanEngine,
39 + private val ocrEngine: OcrEngine,
40 + private val repository: ContactRepository,
41 +) : ViewModel() {
42 +
43 + /** Non-null when rescanning an existing contact (update path). */
44 + var editingContactId: Long? = null
45 +
46 + val isEditing: Boolean
47 + get() = editingContactId != null
48 +
49 + private val _state = MutableStateFlow<ScanUiState>(ScanUiState.Idle)
50 + val state: StateFlow<ScanUiState> = _state.asStateFlow()
51 +
52 + private val _saveDoneId = MutableStateFlow<Long?>(null)
53 + val saveDoneId: StateFlow<Long?> = _saveDoneId.asStateFlow()
54 +
55 + fun onCaptureReady() {
56 + _state.value = ScanUiState.Capturing
57 + }
58 +
59 + fun processCapture(bitmap: Bitmap) {
60 + viewModelScope.launch {
61 + try {
62 + _state.value = ScanUiState.Scanning
63 + val scanned = withContext(Dispatchers.Default) { scanEngine.scan(bitmap) }
64 + _state.value = ScanUiState.OcrRunning
65 + val ocr = withContext(Dispatchers.Default) { ocrEngine.recognize(scanned.bitmap) }
66 + _state.value = ScanUiState.Structuring
67 + var merged = withContext(Dispatchers.Default) {
68 + ContactDraftMerge.merge(null, ocr)
69 + }
70 + val editId = editingContactId
71 + if (editId != null) {
72 + val existing = withContext(Dispatchers.IO) { repository.getById(editId) }
73 + if (existing != null && !existing.notes.isNullOrBlank()) {
74 + merged = merged.copy(note = existing.notes)
75 + }
76 + }
77 + _state.value = ScanUiState.DraftReady(
78 + ContactDraftUi(
79 + card = merged,
80 + rawOcrText = ocr.rawText,
81 + preview = scanned.bitmap,
82 + )
83 + )
84 + } catch (e: ScanException) {
85 + Log.e(TAG, "scan", e)
86 + _state.value = ScanUiState.Error(e.message ?: "scan_erreur")
87 + } catch (e: OcrException) {
88 + Log.e(TAG, "ocr", e)
89 + _state.value = ScanUiState.Error(e.message ?: "ocr_erreur")
90 + } catch (t: Throwable) {
91 + Log.e(TAG, "pipeline", t)
92 + _state.value = ScanUiState.Error(t.message ?: "scan_erreur")
93 + }
94 + }
95 + }
96 +
97 + fun updateDraft(card: ContactCard) {
98 + val current = _state.value
99 + if (current is ScanUiState.DraftReady) {
100 + _state.value = current.copy(draft = current.draft.copy(card = card))
101 + }
102 + }
103 +
104 + /** Enregistre le brouillon dans le carnet local (nouveau contact). Retourne l'id via [saveDoneId]. */
105 + fun saveToCarnet() {
106 + if (editingContactId != null) {
107 + updateFromDraft()
108 + return
109 + }
110 + val draft = (_state.value as? ScanUiState.DraftReady)?.draft ?: return
111 + viewModelScope.launch {
112 + val id = withContext(Dispatchers.IO) {
113 + repository.insertFromCard(
114 + card = draft.card,
115 + cardBitmap = draft.preview,
116 + profileBitmap = null,
117 + )
118 + }
119 + _saveDoneId.value = id
120 + }
121 + }
122 +
123 + /** Met à jour le contact existant (mode rescan) à partir du brouillon courant. */
124 + fun updateFromDraft() {
125 + val editId = editingContactId ?: return
126 + val draft = (_state.value as? ScanUiState.DraftReady)?.draft ?: return
127 + viewModelScope.launch {
128 + withContext(Dispatchers.IO) {
129 + repository.updateFromCard(
130 + id = editId,
131 + card = draft.card,
132 + cardBitmap = draft.preview,
133 + profileBitmap = null,
134 + )
135 + }
136 + _saveDoneId.value = editId
137 + }
138 + }
139 +
140 + fun consumeSaveDone() {
141 + _saveDoneId.value = null
142 + }
143 +
144 + fun reset() {
145 + _state.value = ScanUiState.Idle
146 + }
147 +
148 + fun onCaptureDecodeFailed() {
149 + _state.value = ScanUiState.Error("Capture impossible — réessayez")
150 + }
151 +
152 + companion object {
153 + private const val TAG = "Card2vcfScan"
154 + }
155 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/AlphabetRail.kt
+51 -0
@@ -0,0 +1,51 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import androidx.compose.foundation.clickable
4 +import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Column
6 +import androidx.compose.foundation.layout.fillMaxHeight
7 +import androidx.compose.foundation.layout.padding
8 +import androidx.compose.foundation.layout.width
9 +import androidx.compose.foundation.rememberScrollState
10 +import androidx.compose.foundation.verticalScroll
11 +import androidx.compose.material3.MaterialTheme
12 +import androidx.compose.material3.Text
13 +import androidx.compose.runtime.Composable
14 +import androidx.compose.ui.Alignment
15 +import androidx.compose.ui.Modifier
16 +import androidx.compose.ui.unit.dp
17 +import fr.ebii.card2vcf.ui.theme.Body
18 +import fr.ebii.card2vcf.ui.theme.Ink
19 +
20 +private val RailLetters: List<Char> = ('A'..'Z').toList() + '#'
21 +
22 +@Composable
23 +fun AlphabetRail(
24 + presentLetters: Set<Char>,
25 + onLetter: (Char) -> Unit,
26 + modifier: Modifier = Modifier,
27 +) {
28 + Column(
29 + modifier = modifier
30 + .fillMaxHeight()
31 + .width(28.dp)
32 + .verticalScroll(rememberScrollState())
33 + .padding(vertical = 4.dp),
34 + horizontalAlignment = Alignment.CenterHorizontally,
35 + verticalArrangement = Arrangement.SpaceEvenly,
36 + ) {
37 + for (letter in RailLetters) {
38 + val present = letter in presentLetters
39 + Text(
40 + text = letter.toString(),
41 + style = MaterialTheme.typography.labelSmall,
42 + color = if (present) Ink else Body,
43 + modifier = Modifier
44 + .padding(vertical = 1.dp)
45 + .then(
46 + if (present) Modifier.clickable { onLetter(letter) } else Modifier,
47 + ),
48 + )
49 + }
50 + }
51 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+391 -0
@@ -0,0 +1,391 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.clickable
5 +import androidx.compose.foundation.BorderStroke
6 +import androidx.compose.foundation.background
7 +import androidx.compose.foundation.border
8 +import androidx.compose.foundation.clickable
9 +import androidx.compose.foundation.layout.Arrangement
10 +import androidx.compose.foundation.layout.Box
11 +import androidx.compose.foundation.layout.Column
12 +import androidx.compose.foundation.layout.Row
13 +import androidx.compose.foundation.layout.Spacer
14 +import androidx.compose.foundation.layout.fillMaxSize
15 +import androidx.compose.foundation.layout.fillMaxWidth
16 +import androidx.compose.foundation.layout.height
17 +import androidx.compose.foundation.layout.padding
18 +import androidx.compose.foundation.lazy.LazyColumn
19 +import androidx.compose.foundation.lazy.LazyListState
20 +import androidx.compose.foundation.lazy.items
21 +import androidx.compose.foundation.lazy.rememberLazyListState
22 +import androidx.compose.foundation.shape.RoundedCornerShape
23 +import androidx.compose.material.icons.Icons
24 +import androidx.compose.material.icons.filled.Add
25 +import androidx.compose.material.icons.filled.PhotoCamera
26 +import androidx.compose.material.icons.filled.Settings
27 +import androidx.compose.material.icons.filled.Sync
28 +import androidx.compose.material3.DropdownMenu
29 +import androidx.compose.material3.DropdownMenuItem
30 +import androidx.compose.material3.FloatingActionButton
31 +import androidx.compose.material3.FloatingActionButtonDefaults
32 +import androidx.compose.material3.HorizontalDivider
33 +import androidx.compose.material3.Icon
34 +import androidx.compose.material3.IconButton
35 +import androidx.compose.material3.MaterialTheme
36 +import androidx.compose.material3.OutlinedTextField
37 +import androidx.compose.material3.OutlinedTextFieldDefaults
38 +import androidx.compose.material3.Scaffold
39 +import androidx.compose.material3.SmallFloatingActionButton
40 +import androidx.compose.material3.Text
41 +import androidx.compose.material3.TextButton
42 +import androidx.compose.runtime.Composable
43 +import androidx.compose.runtime.LaunchedEffect
44 +import androidx.compose.runtime.collectAsState
45 +import androidx.compose.runtime.getValue
46 +import androidx.compose.runtime.mutableStateOf
47 +import androidx.compose.runtime.remember
48 +import androidx.compose.runtime.rememberCoroutineScope
49 +import androidx.compose.runtime.setValue
50 +import androidx.compose.ui.Alignment
51 +import androidx.compose.ui.Modifier
52 +import androidx.compose.ui.res.stringResource
53 +import androidx.compose.ui.unit.dp
54 +import fr.ebii.card2vcf.R
55 +import fr.ebii.card2vcf.crm.ContactSort
56 +import fr.ebii.card2vcf.data.CrmContactEntity
57 +import fr.ebii.card2vcf.ui.nav.MainTab
58 +import fr.ebii.card2vcf.ui.nav.MainTabRow
59 +import fr.ebii.card2vcf.ui.sync.SyncBanner
60 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
61 +import fr.ebii.card2vcf.ui.sync.SyncConflictDialog
62 +import fr.ebii.card2vcf.ui.theme.Bordure
63 +import fr.ebii.card2vcf.ui.theme.Fond
64 +import fr.ebii.card2vcf.ui.theme.Ink
65 +import fr.ebii.card2vcf.ui.theme.OnPrimary
66 +import fr.ebii.card2vcf.ui.theme.TexteFaible
67 +import kotlinx.coroutines.launch
68 +
69 +private val Square = RoundedCornerShape(0.dp)
70 +
71 +@Composable
72 +fun CarnetScreen(
73 + viewModel: CarnetViewModel,
74 + onOpenContact: (contactId: Long, sort: ContactSort) -> Unit,
75 + onScan: () -> Unit,
76 + onManual: () -> Unit,
77 + onImportVcf: () -> Unit,
78 + onSettings: () -> Unit,
79 + onOpenProjets: () -> Unit,
80 + syncViewModel: SyncChromeViewModel,
81 + modifier: Modifier = Modifier,
82 +) {
83 + val sort by viewModel.sort.collectAsState()
84 + val query by viewModel.query.collectAsState()
85 + val sections by viewModel.sections.collectAsState()
86 + val letterOffsets by viewModel.letterOffsets.collectAsState()
87 + val listState = rememberLazyListState()
88 + val scope = rememberCoroutineScope()
89 + var sortMenuOpen by remember { mutableStateOf(false) }
90 + var fabMenuOpen by remember { mutableStateOf(false) }
91 + val showRail = query.isBlank() && sort != ContactSort.CREATED_AT
92 + val presentLetters = remember(sections) { sections.map { it.first }.toSet() }
93 + val isEmpty = sections.isEmpty() || sections.all { it.second.isEmpty() }
94 + val syncConfigured by syncViewModel.configured.collectAsState()
95 + val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
96 + val localCalendarAhead by syncViewModel.localCalendarAhead.collectAsState()
97 + val syncing by syncViewModel.syncing.collectAsState()
98 + val syncError by syncViewModel.error.collectAsState()
99 + val syncConflict by syncViewModel.conflictPending.collectAsState()
100 +
101 + LaunchedEffect(Unit) {
102 + syncViewModel.refreshStatus()
103 + }
104 +
105 + Scaffold(
106 + modifier = modifier.fillMaxSize().background(Fond),
107 + containerColor = Fond,
108 + floatingActionButton = {
109 + Column(
110 + horizontalAlignment = Alignment.End,
111 + verticalArrangement = Arrangement.spacedBy(12.dp),
112 + ) {
113 + Box {
114 + SmallFloatingActionButton(
115 + onClick = { fabMenuOpen = true },
116 + shape = Square,
117 + containerColor = Fond,
118 + contentColor = Ink,
119 + elevation = FloatingActionButtonDefaults.elevation(
120 + defaultElevation = 0.dp,
121 + pressedElevation = 0.dp,
122 + ),
123 + modifier = Modifier.border(BorderStroke(1.dp, Bordure), Square),
124 + ) {
125 + Icon(
126 + Icons.Filled.Add,
127 + contentDescription = stringResource(R.string.carnet_fab_ajouter),
128 + )
129 + }
130 + DropdownMenu(
131 + expanded = fabMenuOpen,
132 + onDismissRequest = { fabMenuOpen = false },
133 + shape = Square,
134 + ) {
135 + DropdownMenuItem(
136 + text = { Text(stringResource(R.string.carnet_fab_manuel)) },
137 + onClick = {
138 + fabMenuOpen = false
139 + onManual()
140 + },
141 + )
142 + DropdownMenuItem(
143 + text = { Text(stringResource(R.string.carnet_fab_import)) },
144 + onClick = {
145 + fabMenuOpen = false
146 + onImportVcf()
147 + },
148 + )
149 + }
150 + }
151 + FloatingActionButton(
152 + onClick = onScan,
153 + shape = Square,
154 + containerColor = Ink,
155 + contentColor = OnPrimary,
156 + elevation = FloatingActionButtonDefaults.elevation(
157 + defaultElevation = 0.dp,
158 + pressedElevation = 0.dp,
159 + ),
160 + ) {
161 + Icon(
162 + Icons.Filled.PhotoCamera,
163 + contentDescription = stringResource(R.string.carnet_fab_scanner),
164 + )
165 + }
166 + }
167 + },
168 + ) { padding ->
169 + Column(
170 + Modifier
171 + .fillMaxSize()
172 + .padding(padding)
173 + .padding(horizontal = 18.dp),
174 + ) {
175 + Row(
176 + Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp),
177 + verticalAlignment = Alignment.CenterVertically,
178 + ) {
179 + Text(
180 + text = stringResource(R.string.app_name).uppercase(),
181 + style = MaterialTheme.typography.titleLarge,
182 + color = Ink,
183 + modifier = Modifier.weight(1f),
184 + )
185 + if (syncConfigured) {
186 + IconButton(onClick = { syncViewModel.syncNow() }) {
187 + Icon(
188 + Icons.Filled.Sync,
189 + contentDescription = stringResource(R.string.sync_icone),
190 + tint = Ink,
191 + )
192 + }
193 + }
194 + Box {
195 + TextButton(onClick = { sortMenuOpen = true }, shape = Square) {
196 + Text(sortLabel(sort), color = Ink)
197 + }
198 + DropdownMenu(
199 + expanded = sortMenuOpen,
200 + onDismissRequest = { sortMenuOpen = false },
201 + shape = Square,
202 + ) {
203 + ContactSort.entries.forEach { option ->
204 + DropdownMenuItem(
205 + text = { Text(sortLabel(option)) },
206 + onClick = {
207 + sortMenuOpen = false
208 + viewModel.setSort(option)
209 + },
210 + )
211 + }
212 + }
213 + }
214 + IconButton(onClick = onSettings) {
215 + Icon(
216 + Icons.Filled.Settings,
217 + contentDescription = stringResource(R.string.settings_titre),
218 + tint = Ink,
219 + )
220 + }
221 + }
222 +
223 + MainTabRow(
224 + current = MainTab.CARNET,
225 + onSelect = { tab -> if (tab == MainTab.PROJETS) onOpenProjets() },
226 + )
227 +
228 + OutlinedTextField(
229 + value = query,
230 + onValueChange = viewModel::setQuery,
231 + modifier = Modifier.fillMaxWidth(),
232 + singleLine = true,
233 + shape = Square,
234 + placeholder = {
235 + Text(stringResource(R.string.carnet_recherche), color = TexteFaible)
236 + },
237 + colors = OutlinedTextFieldDefaults.colors(
238 + focusedBorderColor = Ink,
239 + unfocusedBorderColor = Bordure,
240 + focusedTextColor = Ink,
241 + unfocusedTextColor = Ink,
242 + cursorColor = Ink,
243 + ),
244 + )
245 +
246 + SyncBanner(
247 + pendingChanges = pendingRemoteChanges,
248 + localAhead = localCalendarAhead,
249 + syncing = syncing,
250 + error = syncError,
251 + onSyncClick = { syncViewModel.syncNow() },
252 + )
253 +
254 + SyncConflictDialog(
255 + conflict = syncConflict,
256 + onConfirm = { syncViewModel.confirmConflictCancellation() },
257 + onDismiss = { syncViewModel.dismissConflict() },
258 + )
259 +
260 + Spacer(Modifier.height(8.dp))
261 +
262 + if (isEmpty) {
263 + Box(
264 + Modifier.weight(1f).fillMaxWidth(),
265 + contentAlignment = Alignment.Center,
266 + ) {
267 + Text(
268 + stringResource(R.string.carnet_vide),
269 + color = TexteFaible,
270 + style = MaterialTheme.typography.bodyLarge,
271 + )
272 + }
273 + } else {
274 + Row(Modifier.weight(1f).fillMaxWidth()) {
275 + ContactList(
276 + sections = sections,
277 + listState = listState,
278 + showSectionHeaders = sort != ContactSort.CREATED_AT,
279 + onOpenContact = { id -> onOpenContact(id, sort) },
280 + modifier = Modifier.weight(1f),
281 + )
282 + if (showRail) {
283 + AlphabetRail(
284 + presentLetters = presentLetters,
285 + onLetter = { letter ->
286 + val offset = letterOffsets[letter] ?: return@AlphabetRail
287 + scope.launch {
288 + listState.animateScrollToItem(offset)
289 + }
290 + },
291 + )
292 + }
293 + }
294 + }
295 + }
296 + }
297 +}
298 +
299 +@Composable
300 +private fun ContactList(
301 + sections: List<Pair<Char, List<CrmContactEntity>>>,
302 + listState: LazyListState,
303 + onOpenContact: (Long) -> Unit,
304 + modifier: Modifier = Modifier,
305 + showSectionHeaders: Boolean = true,
306 +) {
307 + LazyColumn(
308 + state = listState,
309 + modifier = modifier.fillMaxSize(),
310 + ) {
311 + sections.forEach { (letter, contacts) ->
312 + if (showSectionHeaders) {
313 + item(key = "header-$letter") {
314 + Text(
315 + text = letter.toString(),
316 + style = MaterialTheme.typography.labelMedium,
317 + color = TexteFaible,
318 + modifier = Modifier
319 + .fillMaxWidth()
320 + .background(Fond)
321 + .padding(vertical = 6.dp),
322 + )
323 + }
324 + }
325 + items(contacts, key = { it.id }) { contact ->
326 + ContactRow(
327 + contact = contact,
328 + onClick = { onOpenContact(contact.id) },
329 + )
330 + }
331 + }
332 + }
333 +}
334 +
335 +@Composable
336 +private fun ContactRow(
337 + contact: CrmContactEntity,
338 + onClick: () -> Unit,
339 +) {
340 + Column(
341 + Modifier
342 + .fillMaxWidth()
343 + .clickable(onClick = onClick),
344 + ) {
345 + Column(Modifier.padding(vertical = 10.dp)) {
346 + Text(
347 + text = contactDisplayName(contact),
348 + style = MaterialTheme.typography.bodyLarge,
349 + color = Ink,
350 + )
351 + val subtitle = contactSubtitle(contact)
352 + if (subtitle != null) {
353 + Text(
354 + text = subtitle,
355 + style = MaterialTheme.typography.bodyMedium,
356 + color = TexteFaible,
357 + )
358 + }
359 + }
360 + HorizontalDivider(color = Bordure, thickness = 1.dp)
361 + }
362 +}
363 +
364 +@Composable
365 +private fun sortLabel(sort: ContactSort): String = stringResource(
366 + when (sort) {
367 + ContactSort.LAST_NAME -> R.string.carnet_tri_nom
368 + ContactSort.FIRST_NAME -> R.string.carnet_tri_prenom
369 + ContactSort.COMPANY -> R.string.carnet_tri_entreprise
370 + ContactSort.CREATED_AT -> R.string.carnet_tri_date
371 + },
372 +)
373 +
374 +private fun contactDisplayName(c: CrmContactEntity): String {
375 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
376 + val composed = listOfNotNull(c.firstName, c.lastName)
377 + .joinToString(" ")
378 + .trim()
379 + if (composed.isNotEmpty()) return composed
380 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
381 + return "—"
382 +}
383 +
384 +private fun contactSubtitle(c: CrmContactEntity): String? {
385 + val name = c.fullName?.takeIf { it.isNotBlank() }
386 + ?: listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim().ifEmpty { null }
387 + return when {
388 + name != null -> c.company?.takeIf { it.isNotBlank() }
389 + else -> c.jobTitle?.takeIf { it.isNotBlank() }
390 + }
391 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt
+79 -0
@@ -0,0 +1,79 @@
1 +package fr.ebii.card2vcf.ui.carnet
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.AlphabetIndex
6 +import fr.ebii.card2vcf.crm.ContactSort
7 +import fr.ebii.card2vcf.data.ContactRepository
8 +import fr.ebii.card2vcf.data.CrmContactEntity
9 +import kotlinx.coroutines.ExperimentalCoroutinesApi
10 +import kotlinx.coroutines.flow.MutableStateFlow
11 +import kotlinx.coroutines.flow.SharingStarted
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.asStateFlow
14 +import kotlinx.coroutines.flow.combine
15 +import kotlinx.coroutines.flow.flatMapLatest
16 +import kotlinx.coroutines.flow.flow
17 +import kotlinx.coroutines.flow.stateIn
18 +
19 +@OptIn(ExperimentalCoroutinesApi::class)
20 +class CarnetViewModel(
21 + private val repository: ContactRepository,
22 +) : ViewModel() {
23 +
24 + private val _sort = MutableStateFlow(ContactSort.LAST_NAME)
25 + val sort: StateFlow<ContactSort> = _sort.asStateFlow()
26 +
27 + private val _query = MutableStateFlow("")
28 + val query: StateFlow<String> = _query.asStateFlow()
29 +
30 + private val contactsFlow = _query.flatMapLatest { q ->
31 + if (q.isBlank()) {
32 + repository.observeAll()
33 + } else {
34 + flow {
35 + emit(repository.search(q))
36 + }
37 + }
38 + }
39 +
40 + val contacts: StateFlow<List<CrmContactEntity>> = contactsFlow
41 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
42 +
43 + val sections: StateFlow<List<Pair<Char, List<CrmContactEntity>>>> =
44 + combine(contacts, _sort) { list, sort ->
45 + AlphabetIndex.group(list, sort).toList()
46 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
47 +
48 + val letterOffsets: StateFlow<Map<Char, Int>> =
49 + sections.combine(_sort) { sectionList, _ ->
50 + letterOffsetsOf(sectionList)
51 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
52 +
53 + fun setQuery(value: String) {
54 + _query.value = value
55 + }
56 +
57 + fun setSort(value: ContactSort) {
58 + _sort.value = value
59 + }
60 +
61 + fun cycleSort() {
62 + val values = ContactSort.entries
63 + val next = (values.indexOf(_sort.value) + 1) % values.size
64 + _sort.value = values[next]
65 + }
66 +
67 + companion object {
68 + /** Indices LazyColumn : en-tête de section + lignes contact. */
69 + fun letterOffsetsOf(sections: List<Pair<Char, List<CrmContactEntity>>>): Map<Char, Int> {
70 + val map = LinkedHashMap<Char, Int>()
71 + var index = 0
72 + for ((letter, items) in sections) {
73 + map[letter] = index
74 + index += 1 + items.size
75 + }
76 + return map
77 + }
78 + }
79 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditScreen.kt
+271 -0
@@ -0,0 +1,271 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.net.Uri
4 +import androidx.activity.compose.rememberLauncherForActivityResult
5 +import androidx.activity.result.contract.ActivityResultContracts
6 +import androidx.compose.foundation.Image
7 +import androidx.compose.foundation.background
8 +import androidx.compose.foundation.border
9 +import androidx.compose.foundation.layout.Arrangement
10 +import androidx.compose.foundation.layout.Box
11 +import androidx.compose.foundation.layout.Column
12 +import androidx.compose.foundation.layout.Row
13 +import androidx.compose.foundation.layout.Spacer
14 +import androidx.compose.foundation.layout.fillMaxSize
15 +import androidx.compose.foundation.layout.fillMaxWidth
16 +import androidx.compose.foundation.layout.height
17 +import androidx.compose.foundation.layout.padding
18 +import androidx.compose.foundation.layout.size
19 +import androidx.compose.foundation.rememberScrollState
20 +import androidx.compose.foundation.shape.RoundedCornerShape
21 +import androidx.compose.foundation.verticalScroll
22 +import androidx.compose.material.icons.Icons
23 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
24 +import androidx.compose.material3.Button
25 +import androidx.compose.material3.ButtonDefaults
26 +import androidx.compose.material3.CircularProgressIndicator
27 +import androidx.compose.material3.Icon
28 +import androidx.compose.material3.IconButton
29 +import androidx.compose.material3.MaterialTheme
30 +import androidx.compose.material3.OutlinedButton
31 +import androidx.compose.material3.Text
32 +import androidx.compose.runtime.Composable
33 +import androidx.compose.runtime.LaunchedEffect
34 +import androidx.compose.runtime.collectAsState
35 +import androidx.compose.runtime.getValue
36 +import androidx.compose.runtime.mutableStateOf
37 +import androidx.compose.runtime.remember
38 +import androidx.compose.runtime.setValue
39 +import androidx.compose.ui.Alignment
40 +import androidx.compose.ui.Modifier
41 +import androidx.compose.ui.graphics.asImageBitmap
42 +import androidx.compose.ui.layout.ContentScale
43 +import androidx.compose.ui.platform.LocalContext
44 +import androidx.compose.ui.res.stringResource
45 +import androidx.compose.ui.unit.dp
46 +import androidx.core.content.FileProvider
47 +import fr.ebii.card2vcf.R
48 +import fr.ebii.card2vcf.contact.ContactCard
49 +import fr.ebii.card2vcf.scan.ImageOrientation
50 +import fr.ebii.card2vcf.ui.ContactDraftFields
51 +import fr.ebii.card2vcf.ui.theme.Bordure
52 +import fr.ebii.card2vcf.ui.theme.Encre
53 +import fr.ebii.card2vcf.ui.theme.Fond
54 +import fr.ebii.card2vcf.ui.theme.Ink
55 +import fr.ebii.card2vcf.ui.theme.OnPrimary
56 +import fr.ebii.card2vcf.ui.theme.Surface
57 +import fr.ebii.card2vcf.ui.theme.TexteFaible
58 +import java.io.File
59 +
60 +private val Square = RoundedCornerShape(0.dp)
61 +
62 +@Composable
63 +fun ContactEditScreen(
64 + viewModel: ContactEditViewModel,
65 + onBack: () -> Unit,
66 + onSaved: () -> Unit,
67 + onRescan: (contactId: Long) -> Unit,
68 + modifier: Modifier = Modifier,
69 +) {
70 + val card by viewModel.card.collectAsState()
71 + val profilePreview by viewModel.profilePreview.collectAsState()
72 + val loaded by viewModel.loaded.collectAsState()
73 + val saveDone by viewModel.saveDone.collectAsState()
74 +
75 + LaunchedEffect(saveDone) {
76 + if (saveDone) onSaved()
77 + }
78 +
79 + val context = LocalContext.current
80 + var pendingCaptureUri by remember { mutableStateOf<Uri?>(null) }
81 +
82 + val takePicture = rememberLauncherForActivityResult(
83 + ActivityResultContracts.TakePicture(),
84 + ) { success ->
85 + if (!success) return@rememberLauncherForActivityResult
86 + val uri = pendingCaptureUri ?: return@rememberLauncherForActivityResult
87 + context.contentResolver.openInputStream(uri)?.use { stream ->
88 + ImageOrientation.fromStream(stream)?.bitmap?.let(viewModel::setProfileBitmap)
89 + }
90 + }
91 +
92 + val pickImage = rememberLauncherForActivityResult(
93 + ActivityResultContracts.GetContent(),
94 + ) { uri ->
95 + uri ?: return@rememberLauncherForActivityResult
96 + context.contentResolver.openInputStream(uri)?.use { stream ->
97 + ImageOrientation.fromStream(stream)?.bitmap?.let(viewModel::setProfileBitmap)
98 + }
99 + }
100 +
101 + Column(
102 + modifier
103 + .fillMaxSize()
104 + .background(Fond),
105 + ) {
106 + Row(
107 + Modifier
108 + .fillMaxWidth()
109 + .background(Surface)
110 + .padding(horizontal = 4.dp, vertical = 4.dp),
111 + verticalAlignment = Alignment.CenterVertically,
112 + ) {
113 + IconButton(onClick = onBack) {
114 + Icon(
115 + Icons.AutoMirrored.Outlined.ArrowBack,
116 + contentDescription = stringResource(R.string.scan_retour),
117 + )
118 + }
119 + Text(
120 + stringResource(R.string.edit_titre),
121 + style = MaterialTheme.typography.titleMedium,
122 + color = Encre,
123 + modifier = Modifier.padding(start = 4.dp),
124 + )
125 + }
126 +
127 + if (!loaded) {
128 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
129 + CircularProgressIndicator()
130 + }
131 + return@Column
132 + }
133 +
134 + Column(
135 + Modifier
136 + .weight(1f)
137 + .fillMaxWidth()
138 + .verticalScroll(rememberScrollState())
139 + .padding(horizontal = 18.dp, vertical = 12.dp),
140 + verticalArrangement = Arrangement.spacedBy(14.dp),
141 + ) {
142 + Text(stringResource(R.string.edit_photo_profil), color = Encre)
143 + val preview = profilePreview
144 + if (preview != null) {
145 + Image(
146 + bitmap = preview.asImageBitmap(),
147 + contentDescription = stringResource(R.string.fiche_photo_profil),
148 + modifier = Modifier
149 + .size(96.dp)
150 + .border(1.dp, Bordure, Square),
151 + contentScale = ContentScale.Crop,
152 + )
153 + } else {
154 + Box(
155 + Modifier
156 + .size(96.dp)
157 + .border(1.dp, Bordure, Square)
158 + .background(Surface),
159 + )
160 + }
161 + Row(
162 + Modifier.fillMaxWidth(),
163 + horizontalArrangement = Arrangement.spacedBy(10.dp),
164 + ) {
165 + OutlinedButton(
166 + onClick = {
167 + val dir = File(context.cacheDir, "images").also { it.mkdirs() }
168 + val file = File(dir, "profile_${System.currentTimeMillis()}.jpg")
169 + val uri = FileProvider.getUriForFile(
170 + context,
171 + "${context.packageName}.fileprovider",
172 + file,
173 + )
174 + pendingCaptureUri = uri
175 + takePicture.launch(uri)
176 + },
177 + modifier = Modifier.weight(1f),
178 + shape = Square,
179 + ) {
180 + Text(stringResource(R.string.edit_prendre_photo))
181 + }
182 + OutlinedButton(
183 + onClick = { pickImage.launch("image/*") },
184 + modifier = Modifier.weight(1f),
185 + shape = Square,
186 + ) {
187 + Text(stringResource(R.string.edit_choisir_photo))
188 + }
189 + }
190 +
191 + ContactDraftFields(card = card, onChange = viewModel::updateCard)
192 +
193 + OutlinedButton(
194 + onClick = { onRescan(viewModel.contactId) },
195 + modifier = Modifier.fillMaxWidth(),
196 + shape = Square,
197 + ) {
198 + Text(stringResource(R.string.edit_rescanner))
199 + }
200 + }
201 +
202 + Button(
203 + onClick = viewModel::save,
204 + modifier = Modifier
205 + .fillMaxWidth()
206 + .padding(horizontal = 18.dp, vertical = 12.dp),
207 + shape = Square,
208 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
209 + ) {
210 + Text(stringResource(R.string.edit_enregistrer))
211 + }
212 + }
213 +}
214 +
215 +@Composable
216 +fun ManualContactScreen(
217 + onSave: (ContactCard) -> Unit,
218 + onBack: () -> Unit,
219 + modifier: Modifier = Modifier,
220 +) {
221 + var card by remember { mutableStateOf(ContactCard()) }
222 +
223 + Column(
224 + modifier
225 + .fillMaxSize()
226 + .background(Fond),
227 + ) {
228 + Row(
229 + Modifier
230 + .fillMaxWidth()
231 + .background(Surface)
232 + .padding(horizontal = 4.dp, vertical = 4.dp),
233 + verticalAlignment = Alignment.CenterVertically,
234 + ) {
235 + IconButton(onClick = onBack) {
236 + Icon(
237 + Icons.AutoMirrored.Outlined.ArrowBack,
238 + contentDescription = stringResource(R.string.scan_retour),
239 + )
240 + }
241 + Text(
242 + stringResource(R.string.manual_titre),
243 + style = MaterialTheme.typography.titleMedium,
244 + color = Encre,
245 + modifier = Modifier.padding(start = 4.dp),
246 + )
247 + }
248 + Column(
249 + Modifier
250 + .weight(1f)
251 + .fillMaxWidth()
252 + .verticalScroll(rememberScrollState())
253 + .padding(horizontal = 18.dp, vertical = 12.dp),
254 + verticalArrangement = Arrangement.spacedBy(14.dp),
255 + ) {
256 + Text(stringResource(R.string.manual_sous_titre), color = TexteFaible)
257 + ContactDraftFields(card = card, onChange = { card = it })
258 + Spacer(Modifier.height(8.dp))
259 + }
260 + Button(
261 + onClick = { onSave(card) },
262 + modifier = Modifier
263 + .fillMaxWidth()
264 + .padding(horizontal = 18.dp, vertical = 12.dp),
265 + shape = Square,
266 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
267 + ) {
268 + Text(stringResource(R.string.edit_enregistrer))
269 + }
270 + }
271 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactEditViewModel.kt
+67 -0
@@ -0,0 +1,67 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.graphics.Bitmap
4 +import androidx.lifecycle.ViewModel
5 +import androidx.lifecycle.viewModelScope
6 +import fr.ebii.card2vcf.contact.ContactCard
7 +import fr.ebii.card2vcf.data.ContactRepository
8 +import fr.ebii.card2vcf.data.toCard
9 +import fr.ebii.card2vcf.scan.ImageOrientation
10 +import java.io.File
11 +import kotlinx.coroutines.flow.MutableStateFlow
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.asStateFlow
14 +import kotlinx.coroutines.launch
15 +
16 +class ContactEditViewModel(
17 + private val repository: ContactRepository,
18 + val contactId: Long,
19 +) : ViewModel() {
20 +
21 + private val _card = MutableStateFlow(ContactCard())
22 + val card: StateFlow<ContactCard> = _card.asStateFlow()
23 +
24 + private val _profilePreview = MutableStateFlow<Bitmap?>(null)
25 + val profilePreview: StateFlow<Bitmap?> = _profilePreview.asStateFlow()
26 +
27 + private val _loaded = MutableStateFlow(false)
28 + val loaded: StateFlow<Boolean> = _loaded.asStateFlow()
29 +
30 + private val _saveDone = MutableStateFlow(false)
31 + val saveDone: StateFlow<Boolean> = _saveDone.asStateFlow()
32 +
33 + private var pendingProfile: Bitmap? = null
34 +
35 + init {
36 + viewModelScope.launch {
37 + val entity = repository.getById(contactId) ?: return@launch
38 + _card.value = entity.toCard()
39 + entity.profileImagePath?.takeIf { it.isNotBlank() }?.let { path ->
40 + _profilePreview.value = ImageOrientation.fromFile(File(path))?.bitmap
41 + }
42 + _loaded.value = true
43 + }
44 + }
45 +
46 + fun updateCard(card: ContactCard) {
47 + _card.value = card
48 + }
49 +
50 + fun setProfileBitmap(bitmap: Bitmap) {
51 + pendingProfile = bitmap
52 + _profilePreview.value = bitmap
53 + }
54 +
55 + fun save() {
56 + viewModelScope.launch {
57 + repository.updateFromCard(
58 + id = contactId,
59 + card = _card.value,
60 + cardBitmap = null,
61 + profileBitmap = pendingProfile,
62 + preserveNotesIfBlankDraft = false,
63 + )
64 + _saveDone.value = true
65 + }
66 + }
67 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+325 -0
@@ -0,0 +1,325 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.content.ActivityNotFoundException
4 +import android.content.Intent
5 +import android.net.Uri
6 +import java.io.File
7 +import androidx.compose.foundation.Image
8 +import androidx.compose.foundation.background
9 +import androidx.compose.foundation.border
10 +import androidx.compose.foundation.clickable
11 +import androidx.compose.foundation.layout.Arrangement
12 +import androidx.compose.foundation.layout.Box
13 +import androidx.compose.foundation.layout.Column
14 +import androidx.compose.foundation.layout.Row
15 +import androidx.compose.foundation.layout.Spacer
16 +import androidx.compose.foundation.layout.fillMaxSize
17 +import androidx.compose.foundation.layout.fillMaxWidth
18 +import androidx.compose.foundation.layout.height
19 +import androidx.compose.foundation.layout.padding
20 +import androidx.compose.foundation.layout.size
21 +import androidx.compose.foundation.pager.HorizontalPager
22 +import androidx.compose.foundation.pager.rememberPagerState
23 +import androidx.compose.foundation.rememberScrollState
24 +import androidx.compose.foundation.shape.RoundedCornerShape
25 +import androidx.compose.foundation.verticalScroll
26 +import androidx.compose.material.icons.Icons
27 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
28 +import androidx.compose.material.icons.automirrored.outlined.RotateLeft
29 +import androidx.compose.material3.HorizontalDivider
30 +import androidx.compose.material3.Icon
31 +import androidx.compose.material3.IconButton
32 +import androidx.compose.material3.MaterialTheme
33 +import androidx.compose.material3.OutlinedButton
34 +import androidx.compose.material3.Text
35 +import androidx.compose.material3.TextButton
36 +import androidx.compose.runtime.Composable
37 +import androidx.compose.runtime.collectAsState
38 +import androidx.compose.runtime.getValue
39 +import androidx.compose.runtime.remember
40 +import androidx.compose.ui.Alignment
41 +import androidx.compose.ui.Modifier
42 +import androidx.compose.ui.graphics.ImageBitmap
43 +import androidx.compose.ui.graphics.asImageBitmap
44 +import androidx.compose.ui.layout.ContentScale
45 +import androidx.compose.ui.platform.LocalContext
46 +import androidx.compose.ui.res.stringResource
47 +import androidx.compose.ui.unit.dp
48 +import fr.ebii.card2vcf.R
49 +import fr.ebii.card2vcf.data.CrmContactEntity
50 +import fr.ebii.card2vcf.scan.ImageOrientation
51 +import fr.ebii.card2vcf.ui.theme.Bordure
52 +import fr.ebii.card2vcf.ui.theme.Encre
53 +import fr.ebii.card2vcf.ui.theme.Fond
54 +import fr.ebii.card2vcf.ui.theme.Ink
55 +import fr.ebii.card2vcf.ui.theme.Link
56 +import fr.ebii.card2vcf.ui.theme.Surface
57 +import fr.ebii.card2vcf.ui.theme.TexteFaible
58 +
59 +private val Square = RoundedCornerShape(0.dp)
60 +
61 +@Composable
62 +fun ContactPagerScreen(
63 + viewModel: ContactPagerViewModel,
64 + onBack: () -> Unit,
65 + onEdit: (contactId: Long) -> Unit,
66 + onDuplicates: (contactId: Long) -> Unit,
67 + modifier: Modifier = Modifier,
68 +) {
69 + val contacts by viewModel.contacts.collectAsState()
70 + val duplicateCountById by viewModel.duplicateCountById.collectAsState()
71 +
72 + Column(
73 + modifier
74 + .fillMaxSize()
75 + .background(Fond),
76 + ) {
77 + if (contacts.isEmpty()) {
78 + FicheTopBar(
79 + onBack = onBack,
80 + onEdit = null,
81 + duplicateCount = 0,
82 + onDuplicates = null,
83 + )
84 + Box(
85 + Modifier.fillMaxSize(),
86 + contentAlignment = Alignment.Center,
87 + ) {
88 + Text(stringResource(R.string.carnet_vide), color = TexteFaible)
89 + }
90 + return@Column
91 + }
92 +
93 + val initialPage = viewModel.initialPageIndex(contacts)
94 + val pagerState = rememberPagerState(
95 + initialPage = initialPage,
96 + pageCount = { contacts.size },
97 + )
98 + val current = contacts.getOrNull(pagerState.currentPage) ?: contacts[initialPage]
99 + val dupCount = duplicateCountById[current.id] ?: 0
100 +
101 + FicheTopBar(
102 + onBack = onBack,
103 + onEdit = { onEdit(current.id) },
104 + duplicateCount = dupCount,
105 + onDuplicates = if (dupCount > 0) {
106 + { onDuplicates(current.id) }
107 + } else {
108 + null
109 + },
110 + )
111 +
112 + HorizontalPager(
113 + state = pagerState,
114 + modifier = Modifier.fillMaxSize(),
115 + ) { page ->
116 + ContactFichePage(
117 + contact = contacts[page],
118 + onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) },
119 + )
120 + }
121 + }
122 +}
123 +
124 +@Composable
125 +private fun FicheTopBar(
126 + onBack: () -> Unit,
127 + onEdit: (() -> Unit)?,
128 + duplicateCount: Int,
129 + onDuplicates: (() -> Unit)?,
130 +) {
131 + Row(
132 + Modifier
133 + .fillMaxWidth()
134 + .background(Surface)
135 + .padding(horizontal = 4.dp, vertical = 4.dp),
136 + verticalAlignment = Alignment.CenterVertically,
137 + ) {
138 + IconButton(onClick = onBack) {
139 + Icon(
140 + Icons.AutoMirrored.Outlined.ArrowBack,
141 + contentDescription = stringResource(R.string.scan_retour),
142 + )
143 + }
144 + Spacer(Modifier.weight(1f))
145 + if (onDuplicates != null && duplicateCount > 0) {
146 + TextButton(onClick = onDuplicates, shape = Square) {
147 + Text(
148 + stringResource(R.string.fiche_doublons, duplicateCount),
149 + color = Encre,
150 + )
151 + }
152 + }
153 + if (onEdit != null) {
154 + TextButton(onClick = onEdit, shape = Square) {
155 + Text(stringResource(R.string.fiche_editer), color = Encre)
156 + }
157 + }
158 + }
159 +}
160 +
161 +@Composable
162 +private fun ContactFichePage(
163 + contact: CrmContactEntity,
164 + onRotateCardLeft: () -> Unit,
165 + modifier: Modifier = Modifier,
166 +) {
167 + val context = LocalContext.current
168 + val phones = remember(contact.phones) { contact.phones.filter { it.isNotBlank() } }
169 + val emails = remember(contact.emails) { contact.emails.filter { it.isNotBlank() } }
170 +
171 + fun openEmail(email: String) {
172 + try {
173 + context.startActivity(
174 + Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${Uri.encode(email)}")),
175 + )
176 + } catch (_: ActivityNotFoundException) {
177 + // no-op
178 + }
179 + }
180 +
181 + fun openDial(phone: String) {
182 + try {
183 + val tel = phone.filter { it.isDigit() || it == '+' }
184 + if (tel.isEmpty()) return
185 + context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$tel")))
186 + } catch (_: ActivityNotFoundException) {
187 + // no-op
188 + }
189 + }
190 +
191 + Column(
192 + modifier
193 + .fillMaxSize()
194 + .verticalScroll(rememberScrollState())
195 + .padding(horizontal = 18.dp, vertical = 12.dp),
196 + verticalArrangement = Arrangement.spacedBy(12.dp),
197 + ) {
198 + PathImage(
199 + path = contact.profileImagePath,
200 + revision = contact.updatedAt,
201 + contentDescription = stringResource(R.string.fiche_photo_profil),
202 + modifier = Modifier
203 + .size(96.dp)
204 + .border(1.dp, Bordure, Square),
205 + )
206 +
207 + Text(
208 + text = contactDisplayName(contact),
209 + style = MaterialTheme.typography.titleLarge,
210 + color = Encre,
211 + )
212 +
213 + FieldBlock(stringResource(R.string.scan_champ_prenom), contact.firstName)
214 + FieldBlock(stringResource(R.string.scan_champ_nom_famille), contact.lastName)
215 + FieldBlock(stringResource(R.string.scan_champ_societe), contact.company)
216 + FieldBlock(stringResource(R.string.scan_champ_poste), contact.jobTitle)
217 + ClickableFieldBlock(
218 + label = stringResource(R.string.scan_champ_tel),
219 + values = phones,
220 + onClick = ::openDial,
221 + )
222 + ClickableFieldBlock(
223 + label = stringResource(R.string.scan_champ_email),
224 + values = emails,
225 + onClick = ::openEmail,
226 + )
227 + FieldBlock(stringResource(R.string.scan_champ_site), contact.website)
228 + FieldBlock(stringResource(R.string.scan_champ_adresse), contact.address)
229 +
230 + if (!contact.cardImagePath.isNullOrBlank()) {
231 + HorizontalDivider(color = Bordure, thickness = 1.dp)
232 + PathImage(
233 + path = contact.cardImagePath,
234 + revision = contact.updatedAt,
235 + contentDescription = stringResource(R.string.fiche_photo_carte),
236 + modifier = Modifier
237 + .fillMaxWidth()
238 + .height(220.dp)
239 + .border(1.dp, Bordure, Square),
240 + contentScale = ContentScale.Fit,
241 + )
242 + OutlinedButton(
243 + onClick = onRotateCardLeft,
244 + shape = Square,
245 + modifier = Modifier.fillMaxWidth(),
246 + ) {
247 + Icon(
248 + Icons.AutoMirrored.Outlined.RotateLeft,
249 + contentDescription = null,
250 + tint = Ink,
251 + )
252 + Spacer(Modifier.size(8.dp))
253 + Text(stringResource(R.string.fiche_tourner_gauche), color = Ink)
254 + }
255 + }
256 +
257 + if (!contact.notes.isNullOrBlank()) {
258 + HorizontalDivider(color = Bordure, thickness = 1.dp)
259 + FieldBlock(stringResource(R.string.scan_champ_note), contact.notes)
260 + }
261 + }
262 +}
263 +
264 +@Composable
265 +private fun FieldBlock(label: String, value: String?) {
266 + if (value.isNullOrBlank()) return
267 + Column(Modifier.fillMaxWidth()) {
268 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
269 + Text(value, style = MaterialTheme.typography.bodyLarge, color = Encre)
270 + }
271 +}
272 +
273 +@Composable
274 +private fun ClickableFieldBlock(
275 + label: String,
276 + values: List<String>,
277 + onClick: (String) -> Unit,
278 +) {
279 + if (values.isEmpty()) return
280 + Column(Modifier.fillMaxWidth()) {
281 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
282 + values.forEach { value ->
283 + Text(
284 + text = value,
285 + style = MaterialTheme.typography.bodyLarge,
286 + color = Link,
287 + modifier = Modifier
288 + .fillMaxWidth()
289 + .clickable { onClick(value) }
290 + .padding(vertical = 4.dp),
291 + )
292 + }
293 + }
294 +}
295 +
296 +@Composable
297 +private fun PathImage(
298 + path: String?,
299 + contentDescription: String,
300 + modifier: Modifier = Modifier,
301 + contentScale: ContentScale = ContentScale.Crop,
302 + revision: Long = 0L,
303 +) {
304 + val bitmap: ImageBitmap? = remember(path, revision) {
305 + path?.takeIf { it.isNotBlank() }?.let { p ->
306 + ImageOrientation.fromFile(File(p))?.bitmap?.asImageBitmap()
307 + }
308 + }
309 + if (bitmap != null) {
310 + Image(
311 + bitmap = bitmap,
312 + contentDescription = contentDescription,
313 + modifier = modifier,
314 + contentScale = contentScale,
315 + )
316 + }
317 +}
318 +
319 +private fun contactDisplayName(c: CrmContactEntity): String {
320 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
321 + val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim()
322 + if (composed.isNotEmpty()) return composed
323 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
324 + return "—"
325 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+50 -0
@@ -0,0 +1,50 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.AlphabetIndex
6 +import fr.ebii.card2vcf.crm.ContactSort
7 +import fr.ebii.card2vcf.crm.DuplicateDetector
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.CrmContactEntity
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.flow.SharingStarted
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.map
14 +import kotlinx.coroutines.flow.mapLatest
15 +import kotlinx.coroutines.flow.stateIn
16 +import kotlinx.coroutines.launch
17 +
18 +@OptIn(ExperimentalCoroutinesApi::class)
19 +class ContactPagerViewModel(
20 + private val repository: ContactRepository,
21 + val initialContactId: Long,
22 + val sort: ContactSort,
23 +) : ViewModel() {
24 +
25 + val contacts: StateFlow<List<CrmContactEntity>> = repository.observeAll()
26 + .map { list -> AlphabetIndex.group(list, sort).values.flatten() }
27 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
28 +
29 + val duplicateCountById: StateFlow<Map<Long, Int>> = repository.observeAll()
30 + .mapLatest { list ->
31 + val exclusions = repository.getExclusions()
32 + val clusters = DuplicateDetector.clusters(list, exclusions)
33 + list.associate { c ->
34 + c.id to DuplicateDetector.duplicateCount(c.id, clusters)
35 + }
36 + }
37 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
38 +
39 + fun initialPageIndex(list: List<CrmContactEntity>): Int {
40 + val idx = list.indexOfFirst { it.id == initialContactId }
41 + return if (idx >= 0) idx else 0
42 + }
43 +
44 + /** 90° à gauche (sens antihoraire). */
45 + fun rotateCardLeft(contactId: Long) {
46 + viewModelScope.launch {
47 + repository.rotateCardImage(contactId, degrees = -90)
48 + }
49 + }
50 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewScreen.kt
+477 -0
@@ -0,0 +1,477 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import androidx.compose.foundation.Image
4 +import androidx.compose.foundation.background
5 +import androidx.compose.foundation.border
6 +import androidx.compose.foundation.clickable
7 +import androidx.compose.foundation.layout.Arrangement
8 +import androidx.compose.foundation.layout.Box
9 +import androidx.compose.foundation.layout.Column
10 +import androidx.compose.foundation.layout.Row
11 +import androidx.compose.foundation.layout.Spacer
12 +import androidx.compose.foundation.layout.fillMaxSize
13 +import androidx.compose.foundation.layout.fillMaxWidth
14 +import androidx.compose.foundation.layout.height
15 +import androidx.compose.foundation.layout.padding
16 +import androidx.compose.foundation.layout.size
17 +import androidx.compose.foundation.rememberScrollState
18 +import androidx.compose.foundation.selection.selectable
19 +import androidx.compose.foundation.shape.RoundedCornerShape
20 +import androidx.compose.foundation.verticalScroll
21 +import androidx.compose.material.icons.Icons
22 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
23 +import androidx.compose.material3.Button
24 +import androidx.compose.material3.ButtonDefaults
25 +import androidx.compose.material3.Checkbox
26 +import androidx.compose.material3.CheckboxDefaults
27 +import androidx.compose.material3.HorizontalDivider
28 +import androidx.compose.material3.Icon
29 +import androidx.compose.material3.IconButton
30 +import androidx.compose.material3.MaterialTheme
31 +import androidx.compose.material3.OutlinedButton
32 +import androidx.compose.material3.RadioButton
33 +import androidx.compose.material3.RadioButtonDefaults
34 +import androidx.compose.material3.Text
35 +import androidx.compose.runtime.Composable
36 +import androidx.compose.runtime.LaunchedEffect
37 +import androidx.compose.runtime.collectAsState
38 +import androidx.compose.runtime.getValue
39 +import androidx.compose.runtime.mutableStateOf
40 +import androidx.compose.runtime.remember
41 +import androidx.compose.runtime.setValue
42 +import androidx.compose.ui.Alignment
43 +import androidx.compose.ui.Modifier
44 +import androidx.compose.ui.graphics.asImageBitmap
45 +import androidx.compose.ui.layout.ContentScale
46 +import androidx.compose.ui.res.stringResource
47 +import androidx.compose.ui.semantics.Role
48 +import androidx.compose.ui.unit.dp
49 +import fr.ebii.card2vcf.R
50 +import fr.ebii.card2vcf.crm.MergeFieldChoices
51 +import fr.ebii.card2vcf.crm.NotesMode
52 +import fr.ebii.card2vcf.data.CrmContactEntity
53 +import fr.ebii.card2vcf.scan.ImageOrientation
54 +import fr.ebii.card2vcf.ui.theme.Bordure
55 +import fr.ebii.card2vcf.ui.theme.Encre
56 +import fr.ebii.card2vcf.ui.theme.Fond
57 +import fr.ebii.card2vcf.ui.theme.Ink
58 +import fr.ebii.card2vcf.ui.theme.OnPrimary
59 +import fr.ebii.card2vcf.ui.theme.Surface
60 +import fr.ebii.card2vcf.ui.theme.TexteFaible
61 +import java.io.File
62 +
63 +private val Square = RoundedCornerShape(0.dp)
64 +
65 +@Composable
66 +fun DuplicateReviewScreen(
67 + viewModel: DuplicateReviewViewModel,
68 + onBack: () -> Unit,
69 + onMerged: (keepId: Long) -> Unit,
70 + modifier: Modifier = Modifier,
71 +) {
72 + val cluster by viewModel.cluster.collectAsState()
73 + val selected by viewModel.selectedIds.collectAsState()
74 + val merging by viewModel.merging.collectAsState()
75 + val mergeDoneKeepId by viewModel.mergeDoneKeepId.collectAsState()
76 +
77 + LaunchedEffect(mergeDoneKeepId) {
78 + mergeDoneKeepId?.let(onMerged)
79 + }
80 +
81 + val primary = cluster.firstOrNull { it.id == viewModel.contactId }
82 + val others = cluster.filter { it.id != viewModel.contactId }
83 +
84 + Column(
85 + modifier
86 + .fillMaxSize()
87 + .background(Fond),
88 + ) {
89 + Row(
90 + Modifier
91 + .fillMaxWidth()
92 + .background(Surface)
93 + .padding(horizontal = 4.dp, vertical = 4.dp),
94 + verticalAlignment = Alignment.CenterVertically,
95 + ) {
96 + IconButton(
97 + onClick = {
98 + if (merging) viewModel.cancelMerge() else onBack()
99 + },
100 + ) {
101 + Icon(
102 + Icons.AutoMirrored.Outlined.ArrowBack,
103 + contentDescription = stringResource(R.string.scan_retour),
104 + )
105 + }
106 + Text(
107 + stringResource(
108 + if (merging) R.string.dup_fusion_titre else R.string.dup_revue_titre,
109 + ),
110 + style = MaterialTheme.typography.titleMedium,
111 + color = Encre,
112 + modifier = Modifier.padding(start = 4.dp),
113 + )
114 + }
115 +
116 + if (merging) {
117 + val sources = viewModel.mergeSources(cluster)
118 + if (sources.size < 2) {
119 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
120 + Text(stringResource(R.string.dup_aucun), color = TexteFaible)
121 + }
122 + } else {
123 + MergePanel(
124 + sources = sources,
125 + initial = viewModel.defaultChoices(sources),
126 + onConfirm = { choices -> viewModel.confirmMerge(sources, choices) },
127 + onCancel = viewModel::cancelMerge,
128 + )
129 + }
130 + return@Column
131 + }
132 +
133 + if (others.isEmpty()) {
134 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
135 + Text(stringResource(R.string.dup_aucun), color = TexteFaible)
136 + }
137 + return@Column
138 + }
139 +
140 + Column(
141 + Modifier
142 + .weight(1f)
143 + .fillMaxWidth()
144 + .verticalScroll(rememberScrollState())
145 + .padding(horizontal = 18.dp, vertical = 12.dp),
146 + verticalArrangement = Arrangement.spacedBy(12.dp),
147 + ) {
148 + if (primary != null) {
149 + Text(
150 + stringResource(R.string.dup_contact_courant),
151 + style = MaterialTheme.typography.labelMedium,
152 + color = TexteFaible,
153 + )
154 + ContactSummaryRow(contact = primary, selected = false, onToggle = null)
155 + HorizontalDivider(color = Bordure, thickness = 1.dp)
156 + }
157 +
158 + Text(
159 + stringResource(R.string.dup_autres),
160 + style = MaterialTheme.typography.labelMedium,
161 + color = TexteFaible,
162 + )
163 + others.forEach { c ->
164 + ContactSummaryRow(
165 + contact = c,
166 + selected = c.id in selected,
167 + onToggle = { viewModel.toggleSelect(c.id) },
168 + )
169 + }
170 + }
171 +
172 + Column(
173 + Modifier
174 + .fillMaxWidth()
175 + .padding(horizontal = 18.dp, vertical = 12.dp),
176 + verticalArrangement = Arrangement.spacedBy(8.dp),
177 + ) {
178 + val hasSelection = selected.isNotEmpty()
179 + OutlinedButton(
180 + onClick = viewModel::deleteSelected,
181 + enabled = hasSelection,
182 + modifier = Modifier.fillMaxWidth(),
183 + shape = Square,
184 + ) {
185 + Text(stringResource(R.string.dup_supprimer))
186 + }
187 + OutlinedButton(
188 + onClick = viewModel::markSelectedDifferent,
189 + enabled = hasSelection,
190 + modifier = Modifier.fillMaxWidth(),
191 + shape = Square,
192 + ) {
193 + Text(stringResource(R.string.dup_marquer_differents))
194 + }
195 + Button(
196 + onClick = viewModel::startMerge,
197 + modifier = Modifier.fillMaxWidth(),
198 + shape = Square,
199 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
200 + ) {
201 + Text(stringResource(R.string.dup_fusionner))
202 + }
203 + }
204 + }
205 +}
206 +
207 +@Composable
208 +private fun ContactSummaryRow(
209 + contact: CrmContactEntity,
210 + selected: Boolean,
211 + onToggle: (() -> Unit)?,
212 +) {
213 + Row(
214 + Modifier
215 + .fillMaxWidth()
216 + .border(1.dp, Bordure, Square)
217 + .then(
218 + if (onToggle != null) {
219 + Modifier.clickable(onClick = onToggle)
220 + } else {
221 + Modifier
222 + },
223 + )
224 + .padding(12.dp),
225 + verticalAlignment = Alignment.CenterVertically,
226 + horizontalArrangement = Arrangement.spacedBy(10.dp),
227 + ) {
228 + if (onToggle != null) {
229 + Checkbox(
230 + checked = selected,
231 + onCheckedChange = { onToggle() },
232 + colors = CheckboxDefaults.colors(checkedColor = Ink, checkmarkColor = OnPrimary),
233 + )
234 + }
235 + Column(Modifier.weight(1f)) {
236 + Text(displayName(contact), style = MaterialTheme.typography.bodyLarge, color = Encre)
237 + val detail = listOfNotNull(
238 + contact.emails.firstOrNull()?.takeIf { it.isNotBlank() },
239 + contact.phones.firstOrNull()?.takeIf { it.isNotBlank() },
240 + contact.company?.takeIf { it.isNotBlank() },
241 + ).joinToString(" · ")
242 + if (detail.isNotEmpty()) {
243 + Text(detail, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
244 + }
245 + }
246 + }
247 +}
248 +
249 +@Composable
250 +private fun MergePanel(
251 + sources: List<CrmContactEntity>,
252 + initial: MergeFieldChoices,
253 + onConfirm: (MergeFieldChoices) -> Unit,
254 + onCancel: () -> Unit,
255 +) {
256 + var choices by remember(sources.map { it.id }) { mutableStateOf(initial) }
257 +
258 + Column(
259 + Modifier
260 + .fillMaxSize()
261 + .verticalScroll(rememberScrollState())
262 + .padding(horizontal = 18.dp, vertical = 12.dp),
263 + verticalArrangement = Arrangement.spacedBy(14.dp),
264 + ) {
265 + Text(stringResource(R.string.dup_garder), color = TexteFaible)
266 + sources.forEach { c ->
267 + SourceRadioRow(
268 + label = displayName(c),
269 + selected = choices.keepId == c.id,
270 + onSelect = { choices = choices.copy(keepId = c.id) },
271 + )
272 + }
273 +
274 + HorizontalDivider(color = Bordure, thickness = 1.dp)
275 +
276 + FieldPicker(
277 + label = stringResource(R.string.scan_champ_nom),
278 + sources = sources,
279 + selectedId = choices.fullNameFromId,
280 + valueOf = { it.fullName },
281 + onSelect = { choices = choices.copy(fullNameFromId = it) },
282 + )
283 + FieldPicker(
284 + label = stringResource(R.string.scan_champ_prenom),
285 + sources = sources,
286 + selectedId = choices.firstNameFromId,
287 + valueOf = { it.firstName },
288 + onSelect = { choices = choices.copy(firstNameFromId = it) },
289 + )
290 + FieldPicker(
291 + label = stringResource(R.string.scan_champ_nom_famille),
292 + sources = sources,
293 + selectedId = choices.lastNameFromId,
294 + valueOf = { it.lastName },
295 + onSelect = { choices = choices.copy(lastNameFromId = it) },
296 + )
297 + FieldPicker(
298 + label = stringResource(R.string.scan_champ_societe),
299 + sources = sources,
300 + selectedId = choices.companyFromId,
301 + valueOf = { it.company },
302 + onSelect = { choices = choices.copy(companyFromId = it) },
303 + )
304 + FieldPicker(
305 + label = stringResource(R.string.scan_champ_poste),
306 + sources = sources,
307 + selectedId = choices.jobTitleFromId,
308 + valueOf = { it.jobTitle },
309 + onSelect = { choices = choices.copy(jobTitleFromId = it) },
310 + )
311 + FieldPicker(
312 + label = stringResource(R.string.scan_champ_site),
313 + sources = sources,
314 + selectedId = choices.websiteFromId,
315 + valueOf = { it.website },
316 + onSelect = { choices = choices.copy(websiteFromId = it) },
317 + )
318 + FieldPicker(
319 + label = stringResource(R.string.scan_champ_adresse),
320 + sources = sources,
321 + selectedId = choices.addressFromId,
322 + valueOf = { it.address },
323 + onSelect = { choices = choices.copy(addressFromId = it) },
324 + )
325 +
326 + Text(stringResource(R.string.dup_notes_mode), color = TexteFaible)
327 + SourceRadioRow(
328 + label = stringResource(R.string.dup_notes_garder),
329 + selected = choices.notesMode == NotesMode.KEEP_PRIMARY,
330 + onSelect = { choices = choices.copy(notesMode = NotesMode.KEEP_PRIMARY) },
331 + )
332 + SourceRadioRow(
333 + label = stringResource(R.string.dup_notes_concat),
334 + selected = choices.notesMode == NotesMode.CONCAT,
335 + onSelect = { choices = choices.copy(notesMode = NotesMode.CONCAT) },
336 + )
337 +
338 + ImagePicker(
339 + label = stringResource(R.string.fiche_photo_carte),
340 + sources = sources,
341 + selectedId = choices.cardImageFromId,
342 + pathOf = { it.cardImagePath },
343 + onSelect = { choices = choices.copy(cardImageFromId = it) },
344 + )
345 + ImagePicker(
346 + label = stringResource(R.string.fiche_photo_profil),
347 + sources = sources,
348 + selectedId = choices.profileImageFromId,
349 + pathOf = { it.profileImagePath },
350 + onSelect = { choices = choices.copy(profileImageFromId = it) },
351 + )
352 +
353 + Spacer(Modifier.height(8.dp))
354 + OutlinedButton(
355 + onClick = onCancel,
356 + modifier = Modifier.fillMaxWidth(),
357 + shape = Square,
358 + ) {
359 + Text(stringResource(R.string.dup_annuler))
360 + }
361 + Button(
362 + onClick = { onConfirm(choices) },
363 + modifier = Modifier.fillMaxWidth(),
364 + shape = Square,
365 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
366 + ) {
367 + Text(stringResource(R.string.dup_confirmer_fusion))
368 + }
369 + }
370 +}
371 +
372 +@Composable
373 +private fun FieldPicker(
374 + label: String,
375 + sources: List<CrmContactEntity>,
376 + selectedId: Long,
377 + valueOf: (CrmContactEntity) -> String?,
378 + onSelect: (Long) -> Unit,
379 +) {
380 + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
381 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
382 + sources.forEach { c ->
383 + val value = valueOf(c)?.takeIf { it.isNotBlank() } ?: "—"
384 + SourceRadioRow(
385 + label = "${displayName(c)}: $value",
386 + selected = selectedId == c.id,
387 + onSelect = { onSelect(c.id) },
388 + )
389 + }
390 + }
391 +}
392 +
393 +@Composable
394 +private fun ImagePicker(
395 + label: String,
396 + sources: List<CrmContactEntity>,
397 + selectedId: Long?,
398 + pathOf: (CrmContactEntity) -> String?,
399 + onSelect: (Long?) -> Unit,
400 +) {
401 + val withImage = sources.filter { !pathOf(it).isNullOrBlank() }
402 + if (withImage.isEmpty()) return
403 +
404 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
405 + Text(label, style = MaterialTheme.typography.labelMedium, color = TexteFaible)
406 + SourceRadioRow(
407 + label = stringResource(R.string.dup_image_aucune),
408 + selected = selectedId == null,
409 + onSelect = { onSelect(null) },
410 + )
411 + withImage.forEach { c ->
412 + Row(
413 + Modifier
414 + .fillMaxWidth()
415 + .selectable(
416 + selected = selectedId == c.id,
417 + onClick = { onSelect(c.id) },
418 + role = Role.RadioButton,
419 + )
420 + .padding(vertical = 4.dp),
421 + verticalAlignment = Alignment.CenterVertically,
422 + horizontalArrangement = Arrangement.spacedBy(10.dp),
423 + ) {
424 + RadioButton(
425 + selected = selectedId == c.id,
426 + onClick = { onSelect(c.id) },
427 + colors = RadioButtonDefaults.colors(selectedColor = Ink),
428 + )
429 + val path = pathOf(c)
430 + val bitmap = remember(path) {
431 + path?.let { ImageOrientation.fromFile(File(it))?.bitmap?.asImageBitmap() }
432 + }
433 + if (bitmap != null) {
434 + Image(
435 + bitmap = bitmap,
436 + contentDescription = label,
437 + modifier = Modifier
438 + .size(48.dp)
439 + .border(1.dp, Bordure, Square),
440 + contentScale = ContentScale.Crop,
441 + )
442 + }
443 + Text(displayName(c), color = Encre)
444 + }
445 + }
446 + }
447 +}
448 +
449 +@Composable
450 +private fun SourceRadioRow(
451 + label: String,
452 + selected: Boolean,
453 + onSelect: () -> Unit,
454 +) {
455 + Row(
456 + Modifier
457 + .fillMaxWidth()
458 + .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton)
459 + .padding(vertical = 2.dp),
460 + verticalAlignment = Alignment.CenterVertically,
461 + ) {
462 + RadioButton(
463 + selected = selected,
464 + onClick = onSelect,
465 + colors = RadioButtonDefaults.colors(selectedColor = Ink),
466 + )
467 + Text(label, color = Encre, modifier = Modifier.padding(start = 4.dp))
468 + }
469 +}
470 +
471 +private fun displayName(c: CrmContactEntity): String {
472 + c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
473 + val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim()
474 + if (composed.isNotEmpty()) return composed
475 + c.company?.takeIf { it.isNotBlank() }?.let { return it }
476 + return "#${c.id}"
477 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/contact/DuplicateReviewViewModel.kt
+125 -0
@@ -0,0 +1,125 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.crm.DuplicateDetector
6 +import fr.ebii.card2vcf.crm.MergeFieldChoices
7 +import fr.ebii.card2vcf.crm.NotesMode
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.CrmContactEntity
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.flow.MutableStateFlow
12 +import kotlinx.coroutines.flow.SharingStarted
13 +import kotlinx.coroutines.flow.StateFlow
14 +import kotlinx.coroutines.flow.asStateFlow
15 +import kotlinx.coroutines.flow.combine
16 +import kotlinx.coroutines.flow.mapLatest
17 +import kotlinx.coroutines.flow.stateIn
18 +import kotlinx.coroutines.flow.update
19 +import kotlinx.coroutines.launch
20 +
21 +@OptIn(ExperimentalCoroutinesApi::class)
22 +class DuplicateReviewViewModel(
23 + private val repository: ContactRepository,
24 + val contactId: Long,
25 +) : ViewModel() {
26 +
27 + private val refresh = MutableStateFlow(0)
28 +
29 + val cluster: StateFlow<List<CrmContactEntity>> = combine(
30 + repository.observeAll(),
31 + refresh,
32 + ) { list, _ -> list }
33 + .mapLatest { list ->
34 + val exclusions = repository.getExclusions()
35 + val clusters = DuplicateDetector.clusters(list, exclusions)
36 + val memberIds = clusters[contactId].orEmpty()
37 + list.filter { it.id in memberIds }.sortedBy { it.id }
38 + }
39 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
40 +
41 + private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
42 + val selectedIds: StateFlow<Set<Long>> = _selectedIds.asStateFlow()
43 +
44 + private val _merging = MutableStateFlow(false)
45 + val merging: StateFlow<Boolean> = _merging.asStateFlow()
46 +
47 + private val _mergeDoneKeepId = MutableStateFlow<Long?>(null)
48 + val mergeDoneKeepId: StateFlow<Long?> = _mergeDoneKeepId.asStateFlow()
49 +
50 + fun toggleSelect(id: Long) {
51 + if (id == contactId) return
52 + _selectedIds.update { cur ->
53 + if (id in cur) cur - id else cur + id
54 + }
55 + }
56 +
57 + fun clearSelection() {
58 + _selectedIds.value = emptySet()
59 + }
60 +
61 + fun deleteSelected() {
62 + val ids = _selectedIds.value
63 + if (ids.isEmpty()) return
64 + viewModelScope.launch {
65 + ids.forEach { repository.delete(it) }
66 + _selectedIds.value = emptySet()
67 + refresh.update { it + 1 }
68 + }
69 + }
70 +
71 + fun markSelectedDifferent() {
72 + val ids = _selectedIds.value
73 + if (ids.isEmpty()) return
74 + viewModelScope.launch {
75 + ids.forEach { repository.markDifferent(contactId, it) }
76 + _selectedIds.value = emptySet()
77 + refresh.update { it + 1 }
78 + }
79 + }
80 +
81 + fun startMerge() {
82 + _merging.value = true
83 + }
84 +
85 + fun cancelMerge() {
86 + _merging.value = false
87 + }
88 +
89 + /** Sources = contact courant + sélection (ou tous les autres si sélection vide). */
90 + fun mergeSources(cluster: List<CrmContactEntity>): List<CrmContactEntity> {
91 + val others = cluster.filter { it.id != contactId }
92 + val selected = _selectedIds.value
93 + val peers = if (selected.isEmpty()) others else others.filter { it.id in selected }
94 + val primary = cluster.firstOrNull { it.id == contactId }
95 + return listOfNotNull(primary) + peers
96 + }
97 +
98 + fun defaultChoices(sources: List<CrmContactEntity>): MergeFieldChoices {
99 + val keep = contactId.takeIf { id -> sources.any { it.id == id } }
100 + ?: sources.first().id
101 + return MergeFieldChoices(
102 + fullNameFromId = keep,
103 + firstNameFromId = keep,
104 + lastNameFromId = keep,
105 + companyFromId = keep,
106 + jobTitleFromId = keep,
107 + websiteFromId = keep,
108 + addressFromId = keep,
109 + notesMode = NotesMode.KEEP_PRIMARY,
110 + cardImageFromId = sources.firstOrNull { !it.cardImagePath.isNullOrBlank() }?.id,
111 + profileImageFromId = sources.firstOrNull { !it.profileImagePath.isNullOrBlank() }?.id,
112 + keepId = keep,
113 + )
114 + }
115 +
116 + fun confirmMerge(sources: List<CrmContactEntity>, choices: MergeFieldChoices) {
117 + if (sources.size < 2) return
118 + viewModelScope.launch {
119 + repository.mergeContacts(sources, choices)
120 + _merging.value = false
121 + _selectedIds.value = emptySet()
122 + _mergeDoneKeepId.value = choices.keepId
123 + }
124 + }
125 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/importvcf/VcfImportScreen.kt
+181 -0
@@ -0,0 +1,181 @@
1 +package fr.ebii.card2vcf.ui.importvcf
2 +
3 +import androidx.activity.compose.rememberLauncherForActivityResult
4 +import androidx.activity.result.contract.ActivityResultContracts
5 +import androidx.compose.foundation.background
6 +import androidx.compose.foundation.layout.Arrangement
7 +import androidx.compose.foundation.layout.Box
8 +import androidx.compose.foundation.layout.Column
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.padding
12 +import androidx.compose.foundation.shape.RoundedCornerShape
13 +import androidx.compose.material.icons.Icons
14 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
15 +import androidx.compose.material3.Button
16 +import androidx.compose.material3.ButtonDefaults
17 +import androidx.compose.material3.CircularProgressIndicator
18 +import androidx.compose.material3.Icon
19 +import androidx.compose.material3.IconButton
20 +import androidx.compose.material3.MaterialTheme
21 +import androidx.compose.material3.OutlinedButton
22 +import androidx.compose.material3.Text
23 +import androidx.compose.runtime.Composable
24 +import androidx.compose.runtime.collectAsState
25 +import androidx.compose.runtime.getValue
26 +import androidx.compose.ui.Alignment
27 +import androidx.compose.ui.Modifier
28 +import androidx.compose.ui.platform.LocalContext
29 +import androidx.compose.ui.res.stringResource
30 +import androidx.compose.ui.unit.dp
31 +import fr.ebii.card2vcf.R
32 +import fr.ebii.card2vcf.ui.theme.Encre
33 +import fr.ebii.card2vcf.ui.theme.Fond
34 +import fr.ebii.card2vcf.ui.theme.Ink
35 +import fr.ebii.card2vcf.ui.theme.OnPrimary
36 +import fr.ebii.card2vcf.ui.theme.Surface
37 +import fr.ebii.card2vcf.ui.theme.TexteFaible
38 +
39 +private val Square = RoundedCornerShape(0.dp)
40 +
41 +@Composable
42 +fun VcfImportScreen(
43 + viewModel: VcfImportViewModel,
44 + onBack: () -> Unit,
45 + modifier: Modifier = Modifier,
46 +) {
47 + val state by viewModel.state.collectAsState()
48 + val context = LocalContext.current
49 +
50 + val openDocument = rememberLauncherForActivityResult(
51 + ActivityResultContracts.OpenDocument(),
52 + ) { uri ->
53 + uri ?: return@rememberLauncherForActivityResult
54 + viewModel.importFromUri(context.contentResolver, uri)
55 + }
56 +
57 + fun launchPicker() {
58 + openDocument.launch(arrayOf("text/x-vcard", "text/vcard", "*/*"))
59 + }
60 +
61 + Column(modifier.fillMaxSize().background(Fond)) {
62 + Box(
63 + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp),
64 + ) {
65 + IconButton(onClick = onBack) {
66 + Icon(
67 + Icons.AutoMirrored.Outlined.ArrowBack,
68 + contentDescription = stringResource(R.string.scan_retour),
69 + )
70 + }
71 + Text(
72 + stringResource(R.string.import_titre),
73 + style = MaterialTheme.typography.titleMedium,
74 + modifier = Modifier.align(Alignment.Center),
75 + color = Encre,
76 + )
77 + }
78 +
79 + when (val s = state) {
80 + VcfImportUiState.Idle -> Column(
81 + Modifier
82 + .fillMaxSize()
83 + .padding(18.dp),
84 + verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
85 + horizontalAlignment = Alignment.CenterHorizontally,
86 + ) {
87 + Text(stringResource(R.string.import_sous_titre), color = TexteFaible)
88 + Button(
89 + onClick = { launchPicker() },
90 + modifier = Modifier.fillMaxWidth(),
91 + shape = Square,
92 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
93 + ) {
94 + Text(stringResource(R.string.import_choisir_fichier))
95 + }
96 + }
97 + VcfImportUiState.Loading -> Column(
98 + Modifier.fillMaxSize(),
99 + verticalArrangement = Arrangement.Center,
100 + horizontalAlignment = Alignment.CenterHorizontally,
101 + ) {
102 + CircularProgressIndicator()
103 + Text(
104 + stringResource(R.string.import_en_cours),
105 + modifier = Modifier.padding(top = 16.dp),
106 + color = TexteFaible,
107 + )
108 + }
109 + is VcfImportUiState.Done -> Column(
110 + Modifier
111 + .fillMaxSize()
112 + .padding(18.dp),
113 + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
114 + horizontalAlignment = Alignment.CenterHorizontally,
115 + ) {
116 + Text(
117 + stringResource(R.string.import_resume_titre),
118 + style = MaterialTheme.typography.titleLarge,
119 + color = Encre,
120 + )
121 + Text(
122 + stringResource(R.string.import_resume_importes, s.result.imported),
123 + color = Encre,
124 + )
125 + Text(
126 + stringResource(R.string.import_resume_doublons, s.result.skippedDuplicates),
127 + color = TexteFaible,
128 + )
129 + Text(
130 + stringResource(R.string.import_resume_erreurs, s.result.errors),
131 + color = TexteFaible,
132 + )
133 + Button(
134 + onClick = onBack,
135 + modifier = Modifier.fillMaxWidth(),
136 + shape = Square,
137 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
138 + ) {
139 + Text(stringResource(R.string.import_retour_carnet))
140 + }
141 + OutlinedButton(
142 + onClick = {
143 + viewModel.reset()
144 + launchPicker()
145 + },
146 + modifier = Modifier.fillMaxWidth(),
147 + shape = Square,
148 + ) {
149 + Text(stringResource(R.string.import_autre_fichier))
150 + }
151 + }
152 + is VcfImportUiState.Error -> Column(
153 + Modifier
154 + .fillMaxSize()
155 + .padding(18.dp),
156 + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
157 + horizontalAlignment = Alignment.CenterHorizontally,
158 + ) {
159 + Text(s.message, color = Encre)
160 + Button(
161 + onClick = {
162 + viewModel.reset()
163 + launchPicker()
164 + },
165 + modifier = Modifier.fillMaxWidth(),
166 + shape = Square,
167 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
168 + ) {
169 + Text(stringResource(R.string.import_reessayer))
170 + }
171 + OutlinedButton(
172 + onClick = onBack,
173 + modifier = Modifier.fillMaxWidth(),
174 + shape = Square,
175 + ) {
176 + Text(stringResource(R.string.scan_retour))
177 + }
178 + }
179 + }
180 + }
181 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/importvcf/VcfImportViewModel.kt
+55 -0
@@ -0,0 +1,55 @@
1 +package fr.ebii.card2vcf.ui.importvcf
2 +
3 +import android.content.ContentResolver
4 +import android.net.Uri
5 +import androidx.lifecycle.ViewModel
6 +import androidx.lifecycle.viewModelScope
7 +import fr.ebii.card2vcf.contact.VCardParser
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.VcfImportResult
10 +import kotlinx.coroutines.Dispatchers
11 +import kotlinx.coroutines.flow.MutableStateFlow
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.asStateFlow
14 +import kotlinx.coroutines.launch
15 +import kotlinx.coroutines.withContext
16 +import java.io.BufferedReader
17 +import java.io.InputStreamReader
18 +import java.nio.charset.StandardCharsets
19 +
20 +sealed interface VcfImportUiState {
21 + data object Idle : VcfImportUiState
22 + data object Loading : VcfImportUiState
23 + data class Done(val result: VcfImportResult) : VcfImportUiState
24 + data class Error(val message: String) : VcfImportUiState
25 +}
26 +
27 +class VcfImportViewModel(
28 + private val repository: ContactRepository,
29 +) : ViewModel() {
30 +
31 + private val _state = MutableStateFlow<VcfImportUiState>(VcfImportUiState.Idle)
32 + val state: StateFlow<VcfImportUiState> = _state.asStateFlow()
33 +
34 + fun importFromUri(resolver: ContentResolver, uri: Uri) {
35 + viewModelScope.launch {
36 + _state.value = VcfImportUiState.Loading
37 + try {
38 + val text = withContext(Dispatchers.IO) {
39 + resolver.openInputStream(uri)?.use { stream ->
40 + BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8)).readText()
41 + } ?: throw IllegalStateException("Impossible de lire le fichier")
42 + }
43 + val cards = withContext(Dispatchers.Default) { VCardParser.parse(text) }
44 + val result = withContext(Dispatchers.IO) { repository.importCards(cards) }
45 + _state.value = VcfImportUiState.Done(result)
46 + } catch (t: Throwable) {
47 + _state.value = VcfImportUiState.Error(t.message ?: "import_erreur")
48 + }
49 + }
50 + }
51 +
52 + fun reset() {
53 + _state.value = VcfImportUiState.Idle
54 + }
55 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/kanban/KanbanBoard.kt
+297 -0
@@ -0,0 +1,297 @@
1 +package fr.ebii.card2vcf.ui.kanban
2 +
3 +import androidx.compose.foundation.BorderStroke
4 +import androidx.compose.foundation.border
5 +import androidx.compose.foundation.clickable
6 +import androidx.compose.foundation.layout.Arrangement
7 +import androidx.compose.foundation.layout.Box
8 +import androidx.compose.foundation.layout.Column
9 +import androidx.compose.foundation.layout.Row
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.layout.width
14 +import androidx.compose.foundation.lazy.LazyRow
15 +import androidx.compose.foundation.lazy.itemsIndexed
16 +import androidx.compose.foundation.rememberScrollState
17 +import androidx.compose.foundation.shape.RoundedCornerShape
18 +import androidx.compose.foundation.verticalScroll
19 +import androidx.compose.material3.AlertDialog
20 +import androidx.compose.material3.DropdownMenu
21 +import androidx.compose.material3.DropdownMenuItem
22 +import androidx.compose.material3.MaterialTheme
23 +import androidx.compose.material3.OutlinedTextField
24 +import androidx.compose.material3.OutlinedTextFieldDefaults
25 +import androidx.compose.material3.Text
26 +import androidx.compose.material3.TextButton
27 +import androidx.compose.runtime.Composable
28 +import androidx.compose.runtime.getValue
29 +import androidx.compose.runtime.mutableStateOf
30 +import androidx.compose.runtime.remember
31 +import androidx.compose.runtime.setValue
32 +import androidx.compose.ui.Modifier
33 +import androidx.compose.ui.res.stringResource
34 +import androidx.compose.ui.unit.dp
35 +import fr.ebii.card2vcf.R
36 +import fr.ebii.card2vcf.data.TacheEntity
37 +import fr.ebii.card2vcf.kanban.KanbanFilter
38 +import fr.ebii.card2vcf.sync.ColonneDto
39 +import fr.ebii.card2vcf.ui.theme.Bordure
40 +import fr.ebii.card2vcf.ui.theme.Fond
41 +import fr.ebii.card2vcf.ui.theme.Ink
42 +import fr.ebii.card2vcf.ui.theme.TexteFaible
43 +
44 +private val Square = RoundedCornerShape(0.dp)
45 +private val ColumnWidth = 220.dp
46 +private val BoardHeight = 360.dp
47 +
48 +@Composable
49 +fun KanbanBoard(
50 + colonnes: List<ColonneDto>,
51 + taches: List<TacheEntity>,
52 + filterMode: KanbanFilter.Mode,
53 + onFilterModeChange: (KanbanFilter.Mode) -> Unit,
54 + assignableUsers: List<String>,
55 + onCreateTache: (titre: String, assigneA: String?) -> Unit,
56 + onEditTache: (TacheEntity, titre: String, assigneA: String?) -> Unit,
57 + onDeleteTache: (TacheEntity) -> Unit,
58 + onMoveTache: (TacheEntity, direction: Int) -> Unit,
59 + modifier: Modifier = Modifier,
60 +) {
61 + var dialogTache by remember { mutableStateOf<TacheEntity?>(null) }
62 + var showCreateDialog by remember { mutableStateOf(false) }
63 +
64 + Column(modifier.fillMaxWidth()) {
65 + Row(
66 + Modifier.fillMaxWidth().padding(vertical = 8.dp),
67 + horizontalArrangement = Arrangement.SpaceBetween,
68 + ) {
69 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
70 + FilterChipButton(
71 + label = stringResource(R.string.kanban_filtre_mes_taches),
72 + selected = filterMode == KanbanFilter.Mode.MINE,
73 + onClick = { onFilterModeChange(KanbanFilter.Mode.MINE) },
74 + )
75 + FilterChipButton(
76 + label = stringResource(R.string.kanban_filtre_toutes),
77 + selected = filterMode == KanbanFilter.Mode.ALL,
78 + onClick = { onFilterModeChange(KanbanFilter.Mode.ALL) },
79 + )
80 + }
81 + TextButton(
82 + onClick = { showCreateDialog = true },
83 + shape = Square,
84 + enabled = colonnes.isNotEmpty(),
85 + ) {
86 + Text(stringResource(R.string.kanban_nouvelle_tache), color = Ink)
87 + }
88 + }
89 +
90 + if (colonnes.isEmpty()) {
91 + Text(
92 + stringResource(R.string.kanban_sans_workflow),
93 + color = TexteFaible,
94 + style = MaterialTheme.typography.bodyMedium,
95 + modifier = Modifier.padding(vertical = 8.dp),
96 + )
97 + } else {
98 + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
99 + itemsIndexed(colonnes) { index, colonne ->
100 + KanbanColumn(
101 + colonne = colonne,
102 + taches = taches.filter { it.colonneId == colonne.id }.sortedBy { it.ordre },
103 + canMoveLeft = index > 0,
104 + canMoveRight = index < colonnes.lastIndex,
105 + onEdit = { dialogTache = it },
106 + onDelete = onDeleteTache,
107 + onMoveLeft = { onMoveTache(it, -1) },
108 + onMoveRight = { onMoveTache(it, 1) },
109 + )
110 + }
111 + }
112 + }
113 + }
114 +
115 + if (showCreateDialog) {
116 + TacheDialog(
117 + tache = null,
118 + assignableUsers = assignableUsers,
119 + onDismiss = { showCreateDialog = false },
120 + onConfirm = { titre, assigneA -> onCreateTache(titre, assigneA) },
121 + )
122 + }
123 + dialogTache?.let { tache ->
124 + TacheDialog(
125 + tache = tache,
126 + assignableUsers = assignableUsers,
127 + onDismiss = { dialogTache = null },
128 + onConfirm = { titre, assigneA -> onEditTache(tache, titre, assigneA) },
129 + )
130 + }
131 +}
132 +
133 +@Composable
134 +private fun FilterChipButton(label: String, selected: Boolean, onClick: () -> Unit) {
135 + TextButton(
136 + onClick = onClick,
137 + shape = Square,
138 + modifier = Modifier.border(BorderStroke(1.dp, if (selected) Ink else Bordure), Square),
139 + ) {
140 + Text(label, color = if (selected) Ink else TexteFaible)
141 + }
142 +}
143 +
144 +@Composable
145 +private fun KanbanColumn(
146 + colonne: ColonneDto,
147 + taches: List<TacheEntity>,
148 + canMoveLeft: Boolean,
149 + canMoveRight: Boolean,
150 + onEdit: (TacheEntity) -> Unit,
151 + onDelete: (TacheEntity) -> Unit,
152 + onMoveLeft: (TacheEntity) -> Unit,
153 + onMoveRight: (TacheEntity) -> Unit,
154 +) {
155 + Column(Modifier.width(ColumnWidth).height(BoardHeight)) {
156 + Text(
157 + text = colonne.nom.uppercase(),
158 + style = MaterialTheme.typography.labelMedium,
159 + color = TexteFaible,
160 + modifier = Modifier.padding(bottom = 6.dp),
161 + )
162 + Column(Modifier.verticalScroll(rememberScrollState())) {
163 + taches.forEach { tache ->
164 + TacheCard(
165 + tache = tache,
166 + canMoveLeft = canMoveLeft,
167 + canMoveRight = canMoveRight,
168 + onEdit = { onEdit(tache) },
169 + onDelete = { onDelete(tache) },
170 + onMoveLeft = { onMoveLeft(tache) },
171 + onMoveRight = { onMoveRight(tache) },
172 + )
173 + }
174 + }
175 + }
176 +}
177 +
178 +@Composable
179 +private fun TacheCard(
180 + tache: TacheEntity,
181 + canMoveLeft: Boolean,
182 + canMoveRight: Boolean,
183 + onEdit: () -> Unit,
184 + onDelete: () -> Unit,
185 + onMoveLeft: () -> Unit,
186 + onMoveRight: () -> Unit,
187 +) {
188 + Column(
189 + Modifier
190 + .fillMaxWidth()
191 + .padding(vertical = 4.dp)
192 + .border(BorderStroke(1.dp, Bordure), Square)
193 + .clickable(onClick = onEdit)
194 + .padding(10.dp),
195 + ) {
196 + Text(tache.titre.ifBlank { "—" }, style = MaterialTheme.typography.bodyMedium, color = Ink)
197 + Text(
198 + text = tache.assigneA?.let { stringResource(R.string.kanban_assigne_a, it) }
199 + ?: stringResource(R.string.kanban_non_assigne),
200 + style = MaterialTheme.typography.bodySmall,
201 + color = TexteFaible,
202 + )
203 + Row(
204 + Modifier.fillMaxWidth().padding(top = 4.dp),
205 + horizontalArrangement = Arrangement.SpaceBetween,
206 + ) {
207 + Row {
208 + TextButton(onClick = onMoveLeft, enabled = canMoveLeft, shape = Square) { Text("←") }
209 + TextButton(onClick = onMoveRight, enabled = canMoveRight, shape = Square) { Text("→") }
210 + }
211 + TextButton(onClick = onDelete, shape = Square) {
212 + Text(stringResource(R.string.kanban_supprimer), color = Ink)
213 + }
214 + }
215 + }
216 +}
217 +
218 +@Composable
219 +private fun TacheDialog(
220 + tache: TacheEntity?,
221 + assignableUsers: List<String>,
222 + onDismiss: () -> Unit,
223 + onConfirm: (titre: String, assigneA: String?) -> Unit,
224 +) {
225 + var titre by remember(tache) { mutableStateOf(tache?.titre.orEmpty()) }
226 + var assigneA by remember(tache) { mutableStateOf(tache?.assigneA) }
227 + var assigneMenuOpen by remember { mutableStateOf(false) }
228 + val fieldColors = OutlinedTextFieldDefaults.colors(
229 + focusedBorderColor = Ink,
230 + unfocusedBorderColor = Bordure,
231 + focusedTextColor = Ink,
232 + unfocusedTextColor = Ink,
233 + cursorColor = Ink,
234 + )
235 +
236 + AlertDialog(
237 + onDismissRequest = onDismiss,
238 + shape = Square,
239 + containerColor = Fond,
240 + title = {
241 + Text(
242 + if (tache == null) stringResource(R.string.kanban_nouvelle_tache) else stringResource(R.string.kanban_editer_tache),
243 + color = Ink,
244 + )
245 + },
246 + text = {
247 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
248 + OutlinedTextField(
249 + value = titre,
250 + onValueChange = { titre = it },
251 + modifier = Modifier.fillMaxWidth(),
252 + singleLine = true,
253 + shape = Square,
254 + placeholder = { Text(stringResource(R.string.kanban_champ_titre), color = TexteFaible) },
255 + colors = fieldColors,
256 + )
257 + Box {
258 + TextButton(onClick = { assigneMenuOpen = true }, shape = Square) {
259 + Text(assigneA ?: stringResource(R.string.kanban_non_assigne), color = Ink)
260 + }
261 + DropdownMenu(
262 + expanded = assigneMenuOpen,
263 + onDismissRequest = { assigneMenuOpen = false },
264 + shape = Square,
265 + ) {
266 + DropdownMenuItem(
267 + text = { Text(stringResource(R.string.kanban_non_assigne)) },
268 + onClick = { assigneA = null; assigneMenuOpen = false },
269 + )
270 + assignableUsers.distinct().forEach { user ->
271 + DropdownMenuItem(
272 + text = { Text(user) },
273 + onClick = { assigneA = user; assigneMenuOpen = false },
274 + )
275 + }
276 + }
277 + }
278 + }
279 + },
280 + confirmButton = {
281 + TextButton(
282 + onClick = {
283 + onConfirm(titre, assigneA)
284 + onDismiss()
285 + },
286 + shape = Square,
287 + ) {
288 + Text(stringResource(R.string.kanban_valider), color = Ink)
289 + }
290 + },
291 + dismissButton = {
292 + TextButton(onClick = onDismiss, shape = Square) {
293 + Text(stringResource(R.string.dup_annuler), color = Ink)
294 + }
295 + },
296 + )
297 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+312 -0
@@ -0,0 +1,312 @@
1 +package fr.ebii.card2vcf.ui.nav
2 +
3 +import androidx.compose.foundation.layout.Box
4 +import androidx.compose.foundation.layout.fillMaxSize
5 +import androidx.compose.material3.Text
6 +import androidx.compose.runtime.Composable
7 +import androidx.compose.runtime.LaunchedEffect
8 +import androidx.compose.runtime.remember
9 +import androidx.compose.runtime.rememberCoroutineScope
10 +import androidx.compose.ui.Alignment
11 +import androidx.compose.ui.Modifier
12 +import androidx.compose.ui.platform.LocalContext
13 +import androidx.navigation.NavHostController
14 +import androidx.navigation.NavType
15 +import androidx.navigation.compose.NavHost
16 +import androidx.navigation.compose.composable
17 +import androidx.navigation.compose.rememberNavController
18 +import androidx.navigation.navArgument
19 +import fr.ebii.card2vcf.contact.ContactCard
20 +import fr.ebii.card2vcf.crm.ContactSort
21 +import fr.ebii.card2vcf.data.ContactRepository
22 +import fr.ebii.card2vcf.data.CrmDatabase
23 +import fr.ebii.card2vcf.ocr.TesseractOcrEngine
24 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
25 +import fr.ebii.card2vcf.sync.AilianceApiClient
26 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
27 +import fr.ebii.card2vcf.sync.CalendarBridge
28 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
29 +import fr.ebii.card2vcf.sync.SyncEngine
30 +import fr.ebii.card2vcf.ui.ScanCarteScreen
31 +import fr.ebii.card2vcf.ui.ScanCarteViewModel
32 +import fr.ebii.card2vcf.ui.carnet.CarnetScreen
33 +import fr.ebii.card2vcf.ui.carnet.CarnetViewModel
34 +import fr.ebii.card2vcf.ui.contact.ContactEditScreen
35 +import fr.ebii.card2vcf.ui.contact.ContactEditViewModel
36 +import fr.ebii.card2vcf.ui.contact.ContactPagerScreen
37 +import fr.ebii.card2vcf.ui.contact.ContactPagerViewModel
38 +import fr.ebii.card2vcf.ui.contact.DuplicateReviewScreen
39 +import fr.ebii.card2vcf.ui.contact.DuplicateReviewViewModel
40 +import fr.ebii.card2vcf.ui.contact.ManualContactScreen
41 +import fr.ebii.card2vcf.ui.importvcf.VcfImportScreen
42 +import fr.ebii.card2vcf.ui.importvcf.VcfImportViewModel
43 +import fr.ebii.card2vcf.ui.projets.ProjetDetailScreen
44 +import fr.ebii.card2vcf.ui.projets.ProjetDetailViewModel
45 +import fr.ebii.card2vcf.ui.projets.ProjetsListScreen
46 +import fr.ebii.card2vcf.ui.projets.ProjetsViewModel
47 +import fr.ebii.card2vcf.ui.settings.SettingsScreen
48 +import fr.ebii.card2vcf.ui.settings.SettingsViewModel
49 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
50 +import kotlinx.coroutines.launch
51 +
52 +object Routes {
53 + const val Carnet = "carnet"
54 + const val Fiche = "fiche/{contactId}?sort={sort}"
55 + const val Edit = "edit/{contactId}"
56 + const val Duplicates = "duplicates/{contactId}"
57 + const val Scan = "scan?editId={editId}"
58 + const val Manual = "manual"
59 + const val Draft = "draft"
60 + const val ImportVcf = "import_vcf"
61 + const val Settings = "settings"
62 + const val Projets = "projets"
63 + const val ProjetDetail = "projet/{serverId}"
64 +
65 + fun fiche(contactId: Long, sort: String? = null): String =
66 + if (sort != null) "fiche/$contactId?sort=$sort" else "fiche/$contactId"
67 +
68 + fun edit(contactId: Long): String = "edit/$contactId"
69 +
70 + fun duplicates(contactId: Long): String = "duplicates/$contactId"
71 +
72 + fun scan(editId: Long? = null): String =
73 + if (editId != null) "scan?editId=$editId" else "scan"
74 +
75 + fun projetDetail(serverId: String): String = "projet/$serverId"
76 +}
77 +
78 +@Composable
79 +fun Card2vcfNavHost(
80 + repository: ContactRepository,
81 + database: CrmDatabase,
82 + onCreateContact: (ContactCard) -> Unit,
83 + onExportVcf: (ContactCard) -> Unit,
84 + modifier: Modifier = Modifier,
85 + navController: NavHostController = rememberNavController(),
86 +) {
87 + val carnetVm = remember(repository) { CarnetViewModel(repository) }
88 + val context = LocalContext.current
89 + val scanVm = remember(repository) {
90 + ScanCarteViewModel(
91 + scanEngine = OpenCvScanEngine(),
92 + ocrEngine = TesseractOcrEngine(context.applicationContext),
93 + repository = repository,
94 + )
95 + }
96 + val scope = rememberCoroutineScope()
97 + val credentialsStore = remember { SyncCredentialsStore(context.applicationContext) }
98 + val calendarBindingsStore = remember { CalendarBindingsStore(context.applicationContext) }
99 + val calendarBridge = remember { CalendarBridge(context.applicationContext.contentResolver) }
100 + val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge) {
101 + SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge)
102 + }
103 + val projetsVm = remember(database) { ProjetsViewModel(database) }
104 + val syncChromeVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, database) {
105 + SyncChromeViewModel(
106 + credentialsStore = credentialsStore,
107 + calendarBindingsStore = calendarBindingsStore,
108 + calendarBridge = calendarBridge,
109 + engineFactory = {
110 + val baseUrl = credentialsStore.baseUrl
111 + val apiKey = credentialsStore.apiKey
112 + if (baseUrl != null && apiKey != null) {
113 + SyncEngine(
114 + AilianceApiClient(baseUrl, apiKey),
115 + database,
116 + fr.ebii.card2vcf.data.ContactImageStore(context.applicationContext),
117 + )
118 + } else {
119 + null
120 + }
121 + },
122 + )
123 + }
124 +
125 + NavHost(
126 + navController = navController,
127 + startDestination = Routes.Carnet,
128 + modifier = modifier,
129 + ) {
130 + composable(Routes.Carnet) {
131 + CarnetScreen(
132 + viewModel = carnetVm,
133 + onOpenContact = { id, sort ->
134 + navController.navigate(Routes.fiche(id, sort.name))
135 + },
136 + onScan = { navController.navigate(Routes.scan()) },
137 + onManual = { navController.navigate(Routes.Manual) },
138 + onImportVcf = { navController.navigate(Routes.ImportVcf) },
139 + onSettings = { navController.navigate(Routes.Settings) },
140 + onOpenProjets = {
141 + navController.navigate(Routes.Projets) {
142 + popUpTo(Routes.Carnet) { inclusive = false }
143 + launchSingleTop = true
144 + }
145 + },
146 + syncViewModel = syncChromeVm,
147 + )
148 + }
149 + composable(Routes.Projets) {
150 + ProjetsListScreen(
151 + viewModel = projetsVm,
152 + onOpenCarnet = {
153 + navController.navigate(Routes.Carnet) {
154 + popUpTo(Routes.Carnet) { inclusive = false }
155 + launchSingleTop = true
156 + }
157 + },
158 + onOpenProjet = { serverId -> navController.navigate(Routes.projetDetail(serverId)) },
159 + onSettings = { navController.navigate(Routes.Settings) },
160 + syncViewModel = syncChromeVm,
161 + )
162 + }
163 + composable(
164 + route = Routes.ProjetDetail,
165 + arguments = listOf(navArgument("serverId") { type = NavType.StringType }),
166 + ) { entry ->
167 + val serverId = entry.arguments?.getString("serverId") ?: return@composable
168 + val projetDetailVm = remember(database, serverId) {
169 + ProjetDetailViewModel(database, credentialsStore, serverId)
170 + }
171 + ProjetDetailScreen(
172 + viewModel = projetDetailVm,
173 + onBack = { navController.popBackStack() },
174 + )
175 + }
176 + composable(
177 + route = Routes.Fiche,
178 + arguments = listOf(
179 + navArgument("contactId") { type = NavType.LongType },
180 + navArgument("sort") {
181 + type = NavType.StringType
182 + nullable = true
183 + defaultValue = null
184 + },
185 + ),
186 + ) { entry ->
187 + val id = entry.arguments?.getLong("contactId") ?: return@composable
188 + val sortName = entry.arguments?.getString("sort")
189 + val sort = sortName
190 + ?.let { runCatching { ContactSort.valueOf(it) }.getOrNull() }
191 + ?: ContactSort.LAST_NAME
192 + val pagerVm = remember(repository, id, sort) {
193 + ContactPagerViewModel(repository, id, sort)
194 + }
195 + ContactPagerScreen(
196 + viewModel = pagerVm,
197 + onBack = { navController.popBackStack() },
198 + onEdit = { contactId -> navController.navigate(Routes.edit(contactId)) },
199 + onDuplicates = { contactId ->
200 + navController.navigate(Routes.duplicates(contactId))
201 + },
202 + )
203 + }
204 + composable(
205 + route = Routes.Edit,
206 + arguments = listOf(
207 + navArgument("contactId") { type = NavType.LongType },
208 + ),
209 + ) { entry ->
210 + val id = entry.arguments?.getLong("contactId") ?: return@composable
211 + val editVm = remember(repository, id) {
212 + ContactEditViewModel(repository, id)
213 + }
214 + ContactEditScreen(
215 + viewModel = editVm,
216 + onBack = { navController.popBackStack() },
217 + onSaved = { navController.popBackStack() },
218 + onRescan = { contactId ->
219 + navController.navigate(Routes.scan(contactId))
220 + },
221 + )
222 + }
223 + composable(
224 + route = Routes.Duplicates,
225 + arguments = listOf(
226 + navArgument("contactId") { type = NavType.LongType },
227 + ),
228 + ) { entry ->
229 + val id = entry.arguments?.getLong("contactId") ?: return@composable
230 + val dupVm = remember(repository, id) {
231 + DuplicateReviewViewModel(repository, id)
232 + }
233 + DuplicateReviewScreen(
234 + viewModel = dupVm,
235 + onBack = { navController.popBackStack() },
236 + onMerged = { keepId ->
237 + navController.navigate(Routes.fiche(keepId)) {
238 + popUpTo(Routes.Carnet) { inclusive = false }
239 + }
240 + },
241 + )
242 + }
243 + composable(
244 + route = Routes.Scan,
245 + arguments = listOf(
246 + navArgument("editId") {
247 + type = NavType.StringType
248 + nullable = true
249 + defaultValue = null
250 + },
251 + ),
252 + ) { entry ->
253 + val editId = entry.arguments?.getString("editId")?.toLongOrNull()
254 + LaunchedEffect(editId) {
255 + scanVm.editingContactId = editId
256 + scanVm.reset()
257 + }
258 + ScanCarteScreen(
259 + viewModel = scanVm,
260 + onCreateContact = onCreateContact,
261 + onExportVcf = onExportVcf,
262 + onContactUpdated = { contactId ->
263 + scanVm.editingContactId = null
264 + scanVm.reset()
265 + navController.navigate(Routes.fiche(contactId)) {
266 + popUpTo(Routes.Carnet) { inclusive = false }
267 + }
268 + },
269 + onBack = { navController.popBackStack() },
270 + )
271 + }
272 + composable(Routes.Manual) {
273 + ManualContactScreen(
274 + onBack = { navController.popBackStack() },
275 + onSave = { card ->
276 + scope.launch {
277 + val id = repository.insertFromCard(card, null, null)
278 + navController.navigate(Routes.fiche(id)) {
279 + popUpTo(Routes.Carnet) { inclusive = false }
280 + }
281 + }
282 + },
283 + )
284 + }
285 + composable(Routes.Draft) {
286 + PlaceholderRoute("Brouillon")
287 + }
288 + composable(Routes.ImportVcf) {
289 + val importVm = remember(repository) { VcfImportViewModel(repository) }
290 + VcfImportScreen(
291 + viewModel = importVm,
292 + onBack = { navController.popBackStack() },
293 + )
294 + }
295 + composable(Routes.Settings) {
296 + SettingsScreen(
297 + viewModel = settingsVm,
298 + onBack = { navController.popBackStack() },
299 + )
300 + }
301 + }
302 +}
303 +
304 +@Composable
305 +private fun PlaceholderRoute(label: String) {
306 + Box(
307 + modifier = Modifier.fillMaxSize(),
308 + contentAlignment = Alignment.Center,
309 + ) {
310 + Text(label)
311 + }
312 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/nav/MainTabRow.kt
+46 -0
@@ -0,0 +1,46 @@
1 +package fr.ebii.card2vcf.ui.nav
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Row
5 +import androidx.compose.foundation.layout.fillMaxWidth
6 +import androidx.compose.foundation.shape.RoundedCornerShape
7 +import androidx.compose.material3.MaterialTheme
8 +import androidx.compose.material3.Text
9 +import androidx.compose.material3.TextButton
10 +import androidx.compose.runtime.Composable
11 +import androidx.compose.ui.Modifier
12 +import androidx.compose.ui.res.stringResource
13 +import androidx.compose.ui.unit.dp
14 +import fr.ebii.card2vcf.R
15 +import fr.ebii.card2vcf.ui.theme.Ink
16 +import fr.ebii.card2vcf.ui.theme.TexteFaible
17 +
18 +private val Square = RoundedCornerShape(0.dp)
19 +
20 +enum class MainTab { CARNET, PROJETS }
21 +
22 +/** Onglets éditoriaux Carnet | Projets — pas de NavigationBar Material coloré. */
23 +@Composable
24 +fun MainTabRow(
25 + current: MainTab,
26 + onSelect: (MainTab) -> Unit,
27 + modifier: Modifier = Modifier,
28 +) {
29 + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
30 + MainTab.entries.forEach { tab ->
31 + val selected = tab == current
32 + TextButton(onClick = { onSelect(tab) }, shape = Square) {
33 + Text(
34 + text = stringResource(labelFor(tab)),
35 + color = if (selected) Ink else TexteFaible,
36 + style = if (selected) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium,
37 + )
38 + }
39 + }
40 + }
41 +}
42 +
43 +private fun labelFor(tab: MainTab): Int = when (tab) {
44 + MainTab.CARNET -> R.string.onglet_carnet
45 + MainTab.PROJETS -> R.string.onglet_projets
46 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+219 -0
@@ -0,0 +1,219 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.layout.Box
5 +import androidx.compose.foundation.layout.Column
6 +import androidx.compose.foundation.layout.Spacer
7 +import androidx.compose.foundation.layout.fillMaxSize
8 +import androidx.compose.foundation.layout.fillMaxWidth
9 +import androidx.compose.foundation.layout.height
10 +import androidx.compose.foundation.layout.padding
11 +import androidx.compose.foundation.lazy.LazyColumn
12 +import androidx.compose.foundation.shape.RoundedCornerShape
13 +import androidx.compose.material.icons.Icons
14 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
15 +import androidx.compose.material3.DropdownMenu
16 +import androidx.compose.material3.DropdownMenuItem
17 +import androidx.compose.material3.HorizontalDivider
18 +import androidx.compose.material3.Icon
19 +import androidx.compose.material3.IconButton
20 +import androidx.compose.material3.MaterialTheme
21 +import androidx.compose.material3.OutlinedTextField
22 +import androidx.compose.material3.OutlinedTextFieldDefaults
23 +import androidx.compose.material3.Text
24 +import androidx.compose.material3.TextButton
25 +import androidx.compose.runtime.Composable
26 +import androidx.compose.runtime.collectAsState
27 +import androidx.compose.runtime.getValue
28 +import androidx.compose.runtime.mutableStateOf
29 +import androidx.compose.runtime.remember
30 +import androidx.compose.runtime.setValue
31 +import androidx.compose.ui.Alignment
32 +import androidx.compose.ui.Modifier
33 +import androidx.compose.ui.res.stringResource
34 +import androidx.compose.ui.unit.dp
35 +import fr.ebii.card2vcf.R
36 +import fr.ebii.card2vcf.data.CrmContactEntity
37 +import fr.ebii.card2vcf.data.InteractionEntity
38 +import fr.ebii.card2vcf.ui.kanban.KanbanBoard
39 +import fr.ebii.card2vcf.ui.theme.Bordure
40 +import fr.ebii.card2vcf.ui.theme.Fond
41 +import fr.ebii.card2vcf.ui.theme.Ink
42 +import fr.ebii.card2vcf.ui.theme.Surface
43 +import fr.ebii.card2vcf.ui.theme.TexteFaible
44 +
45 +private val Square = RoundedCornerShape(0.dp)
46 +
47 +@Composable
48 +fun ProjetDetailScreen(
49 + viewModel: ProjetDetailViewModel,
50 + onBack: () -> Unit,
51 + modifier: Modifier = Modifier,
52 +) {
53 + val projet by viewModel.projet.collectAsState()
54 + val membres by viewModel.membres.collectAsState()
55 + val colonnes by viewModel.colonnes.collectAsState()
56 + val visibleTaches by viewModel.visibleTaches.collectAsState()
57 + val filterMode by viewModel.filterMode.collectAsState()
58 + val contacts by viewModel.contactsAvecServerId.collectAsState()
59 + val crContactServerId by viewModel.crContactServerId.collectAsState()
60 + val crSujet by viewModel.crSujet.collectAsState()
61 + val crDescription by viewModel.crDescription.collectAsState()
62 + val crInteractions by viewModel.crInteractions.collectAsState()
63 + val assignableUsers = remember(membres, viewModel.userName) {
64 + (membres.map { it.utilisateur } + viewModel.userName).filter { it.isNotBlank() }
65 + }
66 +
67 + Column(modifier.fillMaxSize().background(Fond)) {
68 + Box(Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp)) {
69 + IconButton(onClick = onBack) {
70 + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.scan_retour))
71 + }
72 + Text(
73 + projet?.nom.orEmpty(),
74 + style = MaterialTheme.typography.titleMedium,
75 + color = Ink,
76 + modifier = Modifier.align(Alignment.Center),
77 + )
78 + }
79 +
80 + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
81 + item {
82 + Spacer(Modifier.height(12.dp))
83 + projet?.description?.takeIf { it.isNotBlank() }?.let { description ->
84 + Text(description, style = MaterialTheme.typography.bodyMedium, color = TexteFaible)
85 + Spacer(Modifier.height(8.dp))
86 + }
87 + if (membres.isNotEmpty()) {
88 + Text(
89 + stringResource(R.string.projet_membres),
90 + style = MaterialTheme.typography.labelMedium,
91 + color = TexteFaible,
92 + )
93 + membres.forEach { membre ->
94 + Text("${membre.utilisateur} — ${membre.niveau}", color = Ink, style = MaterialTheme.typography.bodyMedium)
95 + }
96 + }
97 + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
98 +
99 + KanbanBoard(
100 + colonnes = colonnes,
101 + taches = visibleTaches,
102 + filterMode = filterMode,
103 + onFilterModeChange = viewModel::setFilterMode,
104 + assignableUsers = assignableUsers,
105 + onCreateTache = viewModel::createTache,
106 + onEditTache = viewModel::editTache,
107 + onDeleteTache = viewModel::deleteTache,
108 + onMoveTache = viewModel::moveTache,
109 + )
110 +
111 + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
112 +
113 + CrSection(
114 + contacts = contacts,
115 + selectedContactServerId = crContactServerId,
116 + onSelectContact = viewModel::selectCrContact,
117 + sujet = crSujet,
118 + onSujetChange = viewModel::setCrSujet,
119 + description = crDescription,
120 + onDescriptionChange = viewModel::setCrDescription,
121 + interactions = crInteractions,
122 + onSubmit = viewModel::submitCr,
123 + )
124 + Spacer(Modifier.height(24.dp))
125 + }
126 + }
127 + }
128 +}
129 +
130 +@Composable
131 +private fun CrSection(
132 + contacts: List<CrmContactEntity>,
133 + selectedContactServerId: String?,
134 + onSelectContact: (String?) -> Unit,
135 + sujet: String,
136 + onSujetChange: (String) -> Unit,
137 + description: String,
138 + onDescriptionChange: (String) -> Unit,
139 + interactions: List<InteractionEntity>,
140 + onSubmit: () -> Unit,
141 +) {
142 + var contactMenuOpen by remember { mutableStateOf(false) }
143 + val selectedContact = contacts.find { it.serverId == selectedContactServerId }
144 + val fieldColors = OutlinedTextFieldDefaults.colors(
145 + focusedBorderColor = Ink,
146 + unfocusedBorderColor = Bordure,
147 + focusedTextColor = Ink,
148 + unfocusedTextColor = Ink,
149 + cursorColor = Ink,
150 + )
151 +
152 + Column {
153 + Text(stringResource(R.string.projet_cr_titre), style = MaterialTheme.typography.labelMedium, color = TexteFaible)
154 + Spacer(Modifier.height(6.dp))
155 +
156 + Box {
157 + TextButton(onClick = { contactMenuOpen = true }, shape = Square) {
158 + Text(
159 + selectedContact?.let { contactDisplayName(it) } ?: stringResource(R.string.projet_cr_choisir_contact),
160 + color = Ink,
161 + )
162 + }
163 + DropdownMenu(expanded = contactMenuOpen, onDismissRequest = { contactMenuOpen = false }, shape = Square) {
164 + contacts.forEach { contact ->
165 + DropdownMenuItem(
166 + text = { Text(contactDisplayName(contact)) },
167 + onClick = {
168 + contactMenuOpen = false
169 + onSelectContact(contact.serverId)
170 + },
171 + )
172 + }
173 + }
174 + }
175 +
176 + if (selectedContactServerId != null) {
177 + Spacer(Modifier.height(8.dp))
178 + OutlinedTextField(
179 + value = sujet,
180 + onValueChange = onSujetChange,
181 + modifier = Modifier.fillMaxWidth(),
182 + singleLine = true,
183 + shape = Square,
184 + placeholder = { Text(stringResource(R.string.projet_cr_sujet_placeholder), color = TexteFaible) },
185 + colors = fieldColors,
186 + )
187 + Spacer(Modifier.height(8.dp))
188 + OutlinedTextField(
189 + value = description,
190 + onValueChange = onDescriptionChange,
191 + modifier = Modifier.fillMaxWidth(),
192 + shape = Square,
193 + placeholder = { Text(stringResource(R.string.projet_cr_description_placeholder), color = TexteFaible) },
194 + colors = fieldColors,
195 + )
196 + Spacer(Modifier.height(8.dp))
197 + TextButton(onClick = onSubmit, shape = Square, enabled = sujet.isNotBlank()) {
198 + Text(stringResource(R.string.projet_cr_ajouter), color = Ink)
199 + }
200 +
201 + Spacer(Modifier.height(8.dp))
202 + interactions.forEach { interaction ->
203 + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
204 + Text(interaction.sujet, style = MaterialTheme.typography.bodyMedium, color = Ink)
205 + if (interaction.description.isNotBlank()) {
206 + Text(interaction.description, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
207 + }
208 + }
209 + HorizontalDivider(color = Bordure, thickness = 1.dp)
210 + }
211 + }
212 + }
213 +}
214 +
215 +private fun contactDisplayName(contact: CrmContactEntity): String {
216 + contact.fullName?.takeIf { it.isNotBlank() }?.let { return it }
217 + val composed = listOfNotNull(contact.firstName, contact.lastName).joinToString(" ").trim()
218 + return composed.ifEmpty { "—" }
219 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModel.kt
+280 -0
@@ -0,0 +1,280 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.data.CrmContactEntity
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.InteractionEntity
8 +import fr.ebii.card2vcf.data.ProjetEntity
9 +import fr.ebii.card2vcf.data.TacheEntity
10 +import fr.ebii.card2vcf.kanban.KanbanFilter
11 +import fr.ebii.card2vcf.sync.ColonneDto
12 +import fr.ebii.card2vcf.sync.CreateInteractionRequest
13 +import fr.ebii.card2vcf.sync.CreateTacheRequest
14 +import fr.ebii.card2vcf.sync.MembreProjetDto
15 +import fr.ebii.card2vcf.sync.MoveTacheRequest
16 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
17 +import fr.ebii.card2vcf.sync.SyncOpDao
18 +import fr.ebii.card2vcf.sync.SyncOpEntity
19 +import fr.ebii.card2vcf.sync.UpdateTacheRequest
20 +import fr.ebii.card2vcf.sync.syncJson
21 +import kotlinx.coroutines.ExperimentalCoroutinesApi
22 +import kotlinx.coroutines.flow.MutableStateFlow
23 +import kotlinx.coroutines.flow.SharingStarted
24 +import kotlinx.coroutines.flow.StateFlow
25 +import kotlinx.coroutines.flow.asStateFlow
26 +import kotlinx.coroutines.flow.combine
27 +import kotlinx.coroutines.flow.distinctUntilChanged
28 +import kotlinx.coroutines.flow.flatMapLatest
29 +import kotlinx.coroutines.flow.flow
30 +import kotlinx.coroutines.flow.map
31 +import kotlinx.coroutines.flow.stateIn
32 +import kotlinx.coroutines.launch
33 +import kotlinx.serialization.decodeFromString
34 +import kotlinx.serialization.encodeToString
35 +
36 +/** Fiche projet : membres, kanban (colonnes du workflow), et CR (interaction) sur un contact. */
37 +@OptIn(ExperimentalCoroutinesApi::class)
38 +class ProjetDetailViewModel(
39 + private val database: CrmDatabase,
40 + credentialsStore: SyncCredentialsStore,
41 + private val projetServerId: String,
42 +) : ViewModel() {
43 +
44 + val userName: String = credentialsStore.userName.orEmpty()
45 +
46 + val projet: StateFlow<ProjetEntity?> = database.projetDao().observeAll()
47 + .map { list -> list.find { it.serverId == projetServerId } }
48 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
49 +
50 + val membres: StateFlow<List<MembreProjetDto>> = projet
51 + .map { it?.let { p -> parseMembres(p.membresJson) } ?: emptyList() }
52 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
53 +
54 + val colonnes: StateFlow<List<ColonneDto>> = projet
55 + .map { it?.workflowServerId.orEmpty() }
56 + .distinctUntilChanged()
57 + .flatMapLatest { workflowServerId ->
58 + flow {
59 + val colonnes = if (workflowServerId.isNotBlank()) {
60 + database.workflowDao().getByServerId(workflowServerId)?.let { parseColonnes(it.colonnesJson) }
61 + } else {
62 + null
63 + }
64 + emit(colonnes.orEmpty().sortedBy { it.ordre })
65 + }
66 + }
67 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
68 +
69 + val taches: StateFlow<List<TacheEntity>> = database.tacheDao().observeByProjetServerId(projetServerId)
70 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
71 +
72 + private val _filterMode = MutableStateFlow(KanbanFilter.Mode.MINE)
73 + val filterMode: StateFlow<KanbanFilter.Mode> = _filterMode.asStateFlow()
74 +
75 + val visibleTaches: StateFlow<List<TacheEntity>> = combine(taches, _filterMode) { list, mode ->
76 + KanbanFilter.filter(list, mode, userName)
77 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
78 +
79 + val contactsAvecServerId: StateFlow<List<CrmContactEntity>> = database.crmContactDao().observeAll()
80 + .map { contacts -> contacts.filter { it.serverId != null } }
81 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
82 +
83 + private val _crContactServerId = MutableStateFlow<String?>(null)
84 + val crContactServerId: StateFlow<String?> = _crContactServerId.asStateFlow()
85 +
86 + private val _crSujet = MutableStateFlow("")
87 + val crSujet: StateFlow<String> = _crSujet.asStateFlow()
88 +
89 + private val _crDescription = MutableStateFlow("")
90 + val crDescription: StateFlow<String> = _crDescription.asStateFlow()
91 +
92 + val crInteractions: StateFlow<List<InteractionEntity>> = _crContactServerId
93 + .flatMapLatest { contactServerId ->
94 + if (contactServerId == null) flow { emit(emptyList()) } else database.interactionDao().observeByContactServerId(contactServerId)
95 + }
96 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
97 +
98 + fun setFilterMode(mode: KanbanFilter.Mode) {
99 + _filterMode.value = mode
100 + }
101 +
102 + fun selectCrContact(serverId: String?) {
103 + _crContactServerId.value = serverId
104 + }
105 +
106 + fun setCrSujet(value: String) {
107 + _crSujet.value = value
108 + }
109 +
110 + fun setCrDescription(value: String) {
111 + _crDescription.value = value
112 + }
113 +
114 + fun submitCr() {
115 + val contactServerId = _crContactServerId.value ?: return
116 + val sujet = _crSujet.value.trim()
117 + if (sujet.isEmpty()) return
118 + val description = _crDescription.value.trim()
119 + viewModelScope.launch {
120 + val now = System.currentTimeMillis()
121 + database.interactionDao().upsert(
122 + InteractionEntity(
123 + contactServerId = contactServerId,
124 + sujet = sujet,
125 + description = description,
126 + creePar = userName,
127 + createdAt = now,
128 + ),
129 + )
130 + database.syncOpDao().insert(
131 + SyncOpEntity(
132 + entityType = "interaction",
133 + op = "create",
134 + payloadJson = syncJson.encodeToString(CreateInteractionRequest(sujet = sujet, description = description)),
135 + serverId = contactServerId,
136 + createdAt = now,
137 + ),
138 + )
139 + _crSujet.value = ""
140 + _crDescription.value = ""
141 + }
142 + }
143 +
144 + fun createTache(titre: String, assigneA: String?) {
145 + val titreTrim = titre.trim()
146 + if (titreTrim.isEmpty()) return
147 + val colonneId = colonnes.value.firstOrNull()?.id ?: return
148 + viewModelScope.launch {
149 + val now = System.currentTimeMillis()
150 + val localId = database.tacheDao().upsert(
151 + TacheEntity(
152 + projetLocalId = projet.value?.localId,
153 + projetServerId = projetServerId,
154 + titre = titreTrim,
155 + colonneId = colonneId,
156 + assigneA = assigneA,
157 + auteur = userName,
158 + createdAt = now,
159 + updatedAt = now,
160 + ),
161 + )
162 + database.syncOpDao().insert(
163 + SyncOpEntity(
164 + entityType = "tache",
165 + op = "create",
166 + payloadJson = syncJson.encodeToString(
167 + CreateTacheRequest(titre = titreTrim, colonneId = colonneId, assigneA = assigneA),
168 + ),
169 + localId = localId,
170 + createdAt = now,
171 + ),
172 + )
173 + }
174 + }
175 +
176 + fun editTache(tache: TacheEntity, titre: String, assigneA: String?) {
177 + val titreTrim = titre.trim()
178 + if (titreTrim.isEmpty()) return
179 + viewModelScope.launch {
180 + val now = System.currentTimeMillis()
181 + database.tacheDao().upsert(tache.copy(titre = titreTrim, assigneA = assigneA, updatedAt = now))
182 + val request = UpdateTacheRequest(titre = titreTrim, description = tache.description, assigneA = assigneA)
183 + enqueueTacheMutation(tache, "update", syncJson.encodeToString(request)) {
184 + CreateTacheRequest(titre = titreTrim, description = tache.description, colonneId = tache.colonneId, assigneA = assigneA)
185 + }
186 + }
187 + }
188 +
189 + fun deleteTache(tache: TacheEntity) {
190 + viewModelScope.launch {
191 + val serverId = tache.serverId
192 + if (serverId != null) {
193 + database.tacheDao().deleteByServerId(serverId)
194 + database.syncOpDao().insert(
195 + SyncOpEntity(
196 + entityType = "tache",
197 + op = "delete",
198 + serverId = serverId,
199 + createdAt = System.currentTimeMillis(),
200 + ),
201 + )
202 + } else {
203 + database.tacheDao().deleteByLocalId(tache.localId)
204 + clearPendingCreate(database.syncOpDao(), tache.localId)
205 + }
206 + }
207 + }
208 +
209 + fun moveTache(tache: TacheEntity, direction: Int) {
210 + val ordered = colonnes.value
211 + val currentIndex = ordered.indexOfFirst { it.id == tache.colonneId }
212 + val targetIndex = currentIndex + direction
213 + if (currentIndex == -1 || targetIndex !in ordered.indices) return
214 + val targetColonneId = ordered[targetIndex].id
215 + viewModelScope.launch {
216 + val now = System.currentTimeMillis()
217 + database.tacheDao().upsert(tache.copy(colonneId = targetColonneId, updatedAt = now))
218 + val request = MoveTacheRequest(colonneId = targetColonneId)
219 + enqueueTacheMutation(tache, "move", syncJson.encodeToString(request)) {
220 + CreateTacheRequest(
221 + titre = tache.titre,
222 + description = tache.description,
223 + colonneId = targetColonneId,
224 + assigneA = tache.assigneA,
225 + )
226 + }
227 + }
228 + }
229 +
230 + /** Si la tâche n'a pas encore de `serverId` (create pas encore synchronisé), on patche le create en attente. */
231 + private suspend fun enqueueTacheMutation(
232 + tache: TacheEntity,
233 + op: String,
234 + payloadJson: String,
235 + fallbackCreatePayload: () -> CreateTacheRequest,
236 + ) {
237 + val serverId = tache.serverId
238 + if (serverId != null) {
239 + database.syncOpDao().insert(
240 + SyncOpEntity(
241 + entityType = "tache",
242 + op = op,
243 + payloadJson = payloadJson,
244 + serverId = serverId,
245 + createdAt = System.currentTimeMillis(),
246 + ),
247 + )
248 + return
249 + }
250 + val pendingCreate = database.syncOpDao().listAll()
251 + .find { it.entityType == "tache" && it.op == "create" && it.localId == tache.localId }
252 + if (pendingCreate != null) {
253 + database.syncOpDao().insert(
254 + pendingCreate.copy(payloadJson = syncJson.encodeToString(fallbackCreatePayload())),
255 + )
256 + }
257 + }
258 +
259 + private suspend fun clearPendingCreate(syncOpDao: SyncOpDao, localId: Long) {
260 + syncOpDao.listAll()
261 + .filter { it.entityType == "tache" && it.localId == localId }
262 + .forEach { syncOpDao.deleteById(it.id) }
263 + }
264 +
265 + private companion object {
266 + fun parseMembres(json: String): List<MembreProjetDto> =
267 + try {
268 + syncJson.decodeFromString<List<MembreProjetDto>>(json)
269 + } catch (_: Exception) {
270 + emptyList()
271 + }
272 +
273 + fun parseColonnes(json: String): List<ColonneDto> =
274 + try {
275 + syncJson.decodeFromString<List<ColonneDto>>(json)
276 + } catch (_: Exception) {
277 + emptyList()
278 + }
279 + }
280 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+148 -0
@@ -0,0 +1,148 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.clickable
5 +import androidx.compose.foundation.layout.Box
6 +import androidx.compose.foundation.layout.Column
7 +import androidx.compose.foundation.layout.Row
8 +import androidx.compose.foundation.layout.Spacer
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.lazy.LazyColumn
14 +import androidx.compose.foundation.lazy.items
15 +import androidx.compose.material.icons.Icons
16 +import androidx.compose.material.icons.filled.Settings
17 +import androidx.compose.material.icons.filled.Sync
18 +import androidx.compose.material3.HorizontalDivider
19 +import androidx.compose.material3.Icon
20 +import androidx.compose.material3.IconButton
21 +import androidx.compose.material3.MaterialTheme
22 +import androidx.compose.material3.Scaffold
23 +import androidx.compose.material3.Text
24 +import androidx.compose.runtime.Composable
25 +import androidx.compose.runtime.LaunchedEffect
26 +import androidx.compose.runtime.collectAsState
27 +import androidx.compose.runtime.getValue
28 +import androidx.compose.ui.Alignment
29 +import androidx.compose.ui.Modifier
30 +import androidx.compose.ui.res.stringResource
31 +import androidx.compose.ui.unit.dp
32 +import fr.ebii.card2vcf.R
33 +import fr.ebii.card2vcf.data.ProjetEntity
34 +import fr.ebii.card2vcf.ui.nav.MainTab
35 +import fr.ebii.card2vcf.ui.nav.MainTabRow
36 +import fr.ebii.card2vcf.ui.sync.SyncBanner
37 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
38 +import fr.ebii.card2vcf.ui.sync.SyncConflictDialog
39 +import fr.ebii.card2vcf.ui.theme.Bordure
40 +import fr.ebii.card2vcf.ui.theme.Fond
41 +import fr.ebii.card2vcf.ui.theme.Ink
42 +import fr.ebii.card2vcf.ui.theme.TexteFaible
43 +
44 +@Composable
45 +fun ProjetsListScreen(
46 + viewModel: ProjetsViewModel,
47 + onOpenCarnet: () -> Unit,
48 + onOpenProjet: (serverId: String) -> Unit,
49 + onSettings: () -> Unit,
50 + syncViewModel: SyncChromeViewModel,
51 + modifier: Modifier = Modifier,
52 +) {
53 + val projets by viewModel.projets.collectAsState()
54 + val syncConfigured by syncViewModel.configured.collectAsState()
55 + val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
56 + val localCalendarAhead by syncViewModel.localCalendarAhead.collectAsState()
57 + val syncing by syncViewModel.syncing.collectAsState()
58 + val syncError by syncViewModel.error.collectAsState()
59 + val syncConflict by syncViewModel.conflictPending.collectAsState()
60 +
61 + LaunchedEffect(Unit) {
62 + syncViewModel.refreshStatus()
63 + }
64 +
65 + Scaffold(modifier = modifier.fillMaxSize().background(Fond), containerColor = Fond) { padding ->
66 + Column(
67 + Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp),
68 + ) {
69 + Row(
70 + Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp),
71 + verticalAlignment = Alignment.CenterVertically,
72 + ) {
73 + Text(
74 + text = stringResource(R.string.onglet_projets).uppercase(),
75 + style = MaterialTheme.typography.titleLarge,
76 + color = Ink,
77 + modifier = Modifier.weight(1f),
78 + )
79 + if (syncConfigured) {
80 + IconButton(onClick = { syncViewModel.syncNow() }) {
81 + Icon(Icons.Filled.Sync, contentDescription = stringResource(R.string.sync_icone), tint = Ink)
82 + }
83 + }
84 + IconButton(onClick = onSettings) {
85 + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_titre), tint = Ink)
86 + }
87 + }
88 +
89 + MainTabRow(
90 + current = MainTab.PROJETS,
91 + onSelect = { tab -> if (tab == MainTab.CARNET) onOpenCarnet() },
92 + )
93 +
94 + SyncBanner(
95 + pendingChanges = pendingRemoteChanges,
96 + localAhead = localCalendarAhead,
97 + syncing = syncing,
98 + error = syncError,
99 + onSyncClick = { syncViewModel.syncNow() },
100 + )
101 +
102 + SyncConflictDialog(
103 + conflict = syncConflict,
104 + onConfirm = { syncViewModel.confirmConflictCancellation() },
105 + onDismiss = { syncViewModel.dismissConflict() },
106 + )
107 +
108 + Spacer(Modifier.height(8.dp))
109 +
110 + if (projets.isEmpty()) {
111 + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
112 + Text(
113 + stringResource(R.string.projets_vide),
114 + color = TexteFaible,
115 + style = MaterialTheme.typography.bodyLarge,
116 + )
117 + }
118 + } else {
119 + LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
120 + items(projets, key = { it.localId }) { projet ->
121 + ProjetRow(projet = projet, onClick = { projet.serverId?.let(onOpenProjet) })
122 + }
123 + }
124 + }
125 + }
126 + }
127 +}
128 +
129 +@Composable
130 +private fun ProjetRow(projet: ProjetEntity, onClick: () -> Unit) {
131 + Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
132 + Column(Modifier.padding(vertical = 10.dp)) {
133 + Text(
134 + text = projet.nom.ifBlank { "—" },
135 + style = MaterialTheme.typography.bodyLarge,
136 + color = Ink,
137 + )
138 + if (projet.description.isNotBlank()) {
139 + Text(
140 + text = projet.description,
141 + style = MaterialTheme.typography.bodyMedium,
142 + color = TexteFaible,
143 + )
144 + }
145 + }
146 + HorizontalDivider(color = Bordure, thickness = 1.dp)
147 + }
148 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModel.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.data.CrmDatabase
6 +import fr.ebii.card2vcf.data.ProjetEntity
7 +import kotlinx.coroutines.flow.SharingStarted
8 +import kotlinx.coroutines.flow.StateFlow
9 +import kotlinx.coroutines.flow.stateIn
10 +
11 +class ProjetsViewModel(
12 + database: CrmDatabase,
13 +) : ViewModel() {
14 +
15 + val projets: StateFlow<List<ProjetEntity>> = database.projetDao().observeAll()
16 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
17 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+374 -0
@@ -0,0 +1,374 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import android.Manifest
4 +import android.content.Context
5 +import android.content.pm.PackageManager
6 +import androidx.activity.compose.rememberLauncherForActivityResult
7 +import androidx.activity.result.contract.ActivityResultContracts
8 +import androidx.compose.foundation.background
9 +import androidx.compose.foundation.layout.Arrangement
10 +import androidx.compose.foundation.layout.Box
11 +import androidx.compose.foundation.layout.Column
12 +import androidx.compose.foundation.layout.Row
13 +import androidx.compose.foundation.layout.fillMaxSize
14 +import androidx.compose.foundation.layout.fillMaxWidth
15 +import androidx.compose.foundation.layout.padding
16 +import androidx.compose.foundation.shape.RoundedCornerShape
17 +import androidx.compose.material.icons.Icons
18 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
19 +import androidx.compose.material3.AlertDialog
20 +import androidx.compose.material3.Button
21 +import androidx.compose.material3.ButtonDefaults
22 +import androidx.compose.material3.HorizontalDivider
23 +import androidx.compose.material3.Icon
24 +import androidx.compose.material3.IconButton
25 +import androidx.compose.material3.MaterialTheme
26 +import androidx.compose.material3.OutlinedButton
27 +import androidx.compose.material3.OutlinedTextField
28 +import androidx.compose.material3.OutlinedTextFieldDefaults
29 +import androidx.compose.material3.Switch
30 +import androidx.compose.material3.SwitchDefaults
31 +import androidx.compose.material3.Text
32 +import androidx.compose.material3.TextButton
33 +import androidx.compose.runtime.Composable
34 +import androidx.compose.runtime.LaunchedEffect
35 +import androidx.compose.runtime.collectAsState
36 +import androidx.compose.runtime.getValue
37 +import androidx.compose.runtime.mutableStateOf
38 +import androidx.compose.runtime.remember
39 +import androidx.compose.runtime.setValue
40 +import androidx.compose.ui.Alignment
41 +import androidx.compose.ui.Modifier
42 +import androidx.compose.ui.platform.LocalContext
43 +import androidx.compose.ui.res.stringResource
44 +import androidx.compose.ui.text.input.PasswordVisualTransformation
45 +import androidx.compose.ui.unit.dp
46 +import androidx.core.content.ContextCompat
47 +import fr.ebii.card2vcf.R
48 +import fr.ebii.card2vcf.ui.theme.Bordure
49 +import fr.ebii.card2vcf.ui.theme.Fond
50 +import fr.ebii.card2vcf.ui.theme.Ink
51 +import fr.ebii.card2vcf.ui.theme.OnPrimary
52 +import fr.ebii.card2vcf.ui.theme.Surface
53 +import fr.ebii.card2vcf.ui.theme.TexteFaible
54 +
55 +private val Square = RoundedCornerShape(0.dp)
56 +private val CalendarPermissions = arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
57 +
58 +private fun hasCalendarPermissions(context: Context): Boolean =
59 + CalendarPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
60 +
61 +@Composable
62 +fun SettingsScreen(
63 + viewModel: SettingsViewModel,
64 + onBack: () -> Unit,
65 + modifier: Modifier = Modifier,
66 +) {
67 + val state by viewModel.state.collectAsState()
68 + val context = LocalContext.current
69 + var hasCalendarPermission by remember { mutableStateOf(hasCalendarPermissions(context)) }
70 + val calendarPermissionLauncher = rememberLauncherForActivityResult(
71 + ActivityResultContracts.RequestMultiplePermissions(),
72 + ) { results ->
73 + hasCalendarPermission = results.values.all { it }
74 + }
75 +
76 + LaunchedEffect(hasCalendarPermission) {
77 + viewModel.onCalendarPermissionChanged(hasCalendarPermission)
78 + }
79 +
80 + Column(modifier.fillMaxSize().background(Fond)) {
81 + Box(
82 + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp),
83 + ) {
84 + IconButton(onClick = onBack) {
85 + Icon(
86 + Icons.AutoMirrored.Outlined.ArrowBack,
87 + contentDescription = stringResource(R.string.scan_retour),
88 + )
89 + }
90 + Text(
91 + stringResource(R.string.settings_titre),
92 + style = MaterialTheme.typography.titleMedium,
93 + modifier = Modifier.align(Alignment.Center),
94 + color = Ink,
95 + )
96 + }
97 +
98 + when (state) {
99 + is SettingsUiState.LoggedIn -> LoggedInContent(
100 + state = state as SettingsUiState.LoggedIn,
101 + viewModel = viewModel,
102 + hasCalendarPermission = hasCalendarPermission,
103 + onRequestCalendarPermission = { calendarPermissionLauncher.launch(CalendarPermissions) },
104 + )
105 + is SettingsUiState.LoggedOut -> LoggedOutContent(
106 + state = state as SettingsUiState.LoggedOut,
107 + viewModel = viewModel,
108 + )
109 + }
110 + }
111 +}
112 +
113 +@Composable
114 +private fun LoggedInContent(
115 + state: SettingsUiState.LoggedIn,
116 + viewModel: SettingsViewModel,
117 + hasCalendarPermission: Boolean,
118 + onRequestCalendarPermission: () -> Unit,
119 +) {
120 + Column(
121 + Modifier.fillMaxSize().padding(18.dp),
122 + verticalArrangement = Arrangement.spacedBy(10.dp),
123 + ) {
124 + Text(
125 + stringResource(R.string.settings_connecte_comme, state.userName),
126 + style = MaterialTheme.typography.bodyLarge,
127 + color = Ink,
128 + )
129 + Text(state.baseUrl, color = TexteFaible)
130 + Text(stringResource(R.string.settings_cle_configuree), color = TexteFaible)
131 + TextButton(onClick = viewModel::disconnect, shape = Square) {
132 + Text(stringResource(R.string.settings_deconnecter), color = Ink)
133 + }
134 +
135 + HorizontalDivider(color = Bordure, thickness = 1.dp)
136 +
137 + CalendarSection(
138 + state = state,
139 + viewModel = viewModel,
140 + hasCalendarPermission = hasCalendarPermission,
141 + onRequestCalendarPermission = onRequestCalendarPermission,
142 + )
143 + }
144 +
145 + if (state.pendingRemoval != null) {
146 + AlertDialog(
147 + onDismissRequest = viewModel::dismissRemoveBinding,
148 + shape = Square,
149 + title = { Text(stringResource(R.string.settings_calendrier_retirer_titre), color = Ink) },
150 + text = { Text(stringResource(R.string.settings_calendrier_retirer_message, state.pendingRemoval.displayName), color = Ink) },
151 + confirmButton = {
152 + TextButton(onClick = viewModel::confirmRemoveBinding, shape = Square) {
153 + Text(stringResource(R.string.settings_calendrier_retirer_confirmer), color = Ink)
154 + }
155 + },
156 + dismissButton = {
157 + TextButton(onClick = viewModel::dismissRemoveBinding, shape = Square) {
158 + Text(stringResource(R.string.settings_calendrier_retirer_annuler), color = Ink)
159 + }
160 + },
161 + containerColor = Fond,
162 + )
163 + }
164 +}
165 +
166 +@Composable
167 +private fun CalendarSection(
168 + state: SettingsUiState.LoggedIn,
169 + viewModel: SettingsViewModel,
170 + hasCalendarPermission: Boolean,
171 + onRequestCalendarPermission: () -> Unit,
172 +) {
173 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
174 + Text(
175 + stringResource(R.string.settings_calendriers_titre),
176 + style = MaterialTheme.typography.titleSmall,
177 + color = Ink,
178 + )
179 +
180 + if (!hasCalendarPermission) {
181 + Text(stringResource(R.string.settings_calendrier_permission_manquante), color = TexteFaible)
182 + OutlinedButton(onClick = onRequestCalendarPermission, shape = Square) {
183 + Text(stringResource(R.string.settings_calendrier_autoriser))
184 + }
185 + return
186 + }
187 +
188 + ToggleRow(
189 + label = stringResource(R.string.settings_calendrier_mes_rdv),
190 + checked = state.mesRdvLinked,
191 + enabled = true,
192 + onCheckedChange = viewModel::setMesRdvEnabled,
193 + )
194 +
195 + when {
196 + state.catalogueLoading -> Text(stringResource(R.string.settings_calendrier_chargement), color = TexteFaible)
197 + state.catalogueError != null -> Text(state.catalogueError, color = Ink)
198 + else -> {
199 + RessourceGroup(
200 + titleRes = R.string.settings_calendrier_salles,
201 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_SALLE },
202 + onToggle = viewModel::setRessourceEnabled,
203 + )
204 + RessourceGroup(
205 + titleRes = R.string.settings_calendrier_materiels,
206 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_MATERIEL },
207 + onToggle = viewModel::setRessourceEnabled,
208 + )
209 + RessourceGroup(
210 + titleRes = R.string.settings_calendrier_vehicules,
211 + items = state.ressources.filter { it.kind == SettingsViewModel.KIND_VEHICULE },
212 + onToggle = viewModel::setRessourceEnabled,
213 + )
214 + }
215 + }
216 + }
217 +}
218 +
219 +@Composable
220 +private fun RessourceGroup(
221 + titleRes: Int,
222 + items: List<RessourceToggleState>,
223 + onToggle: (kind: String, serverResourceId: String, enabled: Boolean) -> Unit,
224 +) {
225 + if (items.isEmpty()) return
226 + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
227 + Text(stringResource(titleRes), style = MaterialTheme.typography.labelMedium, color = TexteFaible)
228 + items.forEach { item ->
229 + ToggleRow(
230 + label = if (item.actif) item.nom else "${item.nom} — ${stringResource(R.string.settings_calendrier_ressource_inactive)}",
231 + checked = item.linked,
232 + enabled = item.actif,
233 + onCheckedChange = { enabled -> onToggle(item.kind, item.serverResourceId, enabled) },
234 + )
235 + }
236 + }
237 +}
238 +
239 +@Composable
240 +private fun ToggleRow(
241 + label: String,
242 + checked: Boolean,
243 + enabled: Boolean,
244 + onCheckedChange: (Boolean) -> Unit,
245 +) {
246 + Row(
247 + Modifier.fillMaxWidth(),
248 + verticalAlignment = Alignment.CenterVertically,
249 + horizontalArrangement = Arrangement.SpaceBetween,
250 + ) {
251 + Text(label, color = if (enabled) Ink else TexteFaible, modifier = Modifier.weight(1f))
252 + Switch(
253 + checked = checked,
254 + onCheckedChange = onCheckedChange,
255 + enabled = enabled,
256 + colors = SwitchDefaults.colors(
257 + checkedThumbColor = Ink,
258 + checkedTrackColor = Bordure,
259 + uncheckedThumbColor = TexteFaible,
260 + uncheckedTrackColor = Fond,
261 + uncheckedBorderColor = Bordure,
262 + ),
263 + )
264 + }
265 +}
266 +
267 +@Composable
268 +private fun LoggedOutContent(
269 + state: SettingsUiState.LoggedOut,
270 + viewModel: SettingsViewModel,
271 +) {
272 + val fieldColors = OutlinedTextFieldDefaults.colors(
273 + focusedBorderColor = Ink,
274 + unfocusedBorderColor = Bordure,
275 + focusedTextColor = Ink,
276 + unfocusedTextColor = Ink,
277 + cursorColor = Ink,
278 + )
279 +
280 + Column(
281 + Modifier.fillMaxSize().padding(18.dp),
282 + verticalArrangement = Arrangement.spacedBy(12.dp),
283 + ) {
284 + OutlinedTextField(
285 + value = state.baseUrl,
286 + onValueChange = viewModel::setBaseUrl,
287 + modifier = Modifier.fillMaxWidth(),
288 + singleLine = true,
289 + shape = Square,
290 + placeholder = { Text(stringResource(R.string.settings_url_placeholder), color = TexteFaible) },
291 + colors = fieldColors,
292 + )
293 + OutlinedTextField(
294 + value = state.userName,
295 + onValueChange = viewModel::setUserName,
296 + modifier = Modifier.fillMaxWidth(),
297 + singleLine = true,
298 + shape = Square,
299 + placeholder = { Text(stringResource(R.string.settings_utilisateur_placeholder), color = TexteFaible) },
300 + colors = fieldColors,
301 + )
302 + OutlinedTextField(
303 + value = state.password,
304 + onValueChange = viewModel::setPassword,
305 + modifier = Modifier.fillMaxWidth(),
306 + singleLine = true,
307 + shape = Square,
308 + visualTransformation = PasswordVisualTransformation(),
309 + placeholder = { Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible) },
310 + colors = fieldColors,
311 + )
312 + ToggleRow(
313 + label = stringResource(R.string.settings_allow_cleartext),
314 + checked = state.allowCleartextHttp,
315 + enabled = true,
316 + onCheckedChange = viewModel::setAllowCleartextHttp,
317 + )
318 + Text(
319 + stringResource(R.string.settings_allow_cleartext_hint),
320 + color = TexteFaible,
321 + style = MaterialTheme.typography.bodySmall,
322 + )
323 + if (state.error != null) {
324 + Text(state.error, color = Ink)
325 + }
326 + Button(
327 + onClick = viewModel::obtainKey,
328 + enabled = !state.loading,
329 + modifier = Modifier.fillMaxWidth(),
330 + shape = Square,
331 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
332 + ) {
333 + Text(stringResource(R.string.settings_obtenir_cle))
334 + }
335 + }
336 +
337 + if (state.pendingAuth != null) {
338 + AlertDialog(
339 + onDismissRequest = viewModel::cancelRetype,
340 + shape = Square,
341 + title = { Text(stringResource(R.string.settings_retype_titre), color = Ink) },
342 + text = {
343 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
344 + OutlinedTextField(
345 + value = state.retypePassword,
346 + onValueChange = viewModel::setRetypePassword,
347 + modifier = Modifier.fillMaxWidth(),
348 + singleLine = true,
349 + shape = Square,
350 + visualTransformation = PasswordVisualTransformation(),
351 + placeholder = {
352 + Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible)
353 + },
354 + colors = fieldColors,
355 + )
356 + if (state.retypeError != null) {
357 + Text(state.retypeError, color = Ink)
358 + }
359 + }
360 + },
361 + confirmButton = {
362 + TextButton(onClick = viewModel::confirmRetype, shape = Square) {
363 + Text(stringResource(R.string.settings_retype_confirmer), color = Ink)
364 + }
365 + },
366 + dismissButton = {
367 + TextButton(onClick = viewModel::cancelRetype, shape = Square) {
368 + Text(stringResource(R.string.settings_retype_annuler), color = Ink)
369 + }
370 + },
371 + containerColor = Fond,
372 + )
373 + }
374 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+333 -0
@@ -0,0 +1,333 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
6 +import fr.ebii.card2vcf.sync.AilianceApi
7 +import fr.ebii.card2vcf.sync.AilianceApiClient
8 +import fr.ebii.card2vcf.sync.ApiCleCrypto
9 +import fr.ebii.card2vcf.sync.ApiCleCryptoException
10 +import fr.ebii.card2vcf.sync.AuthCleResponse
11 +import fr.ebii.card2vcf.sync.CalendarBinding
12 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
13 +import fr.ebii.card2vcf.sync.CalendarBridgeApi
14 +import fr.ebii.card2vcf.sync.RessourceItemDto
15 +import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
16 +import fr.ebii.card2vcf.sync.ServerUrlPolicy
17 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
18 +import kotlinx.coroutines.CoroutineDispatcher
19 +import kotlinx.coroutines.Dispatchers
20 +import kotlinx.coroutines.flow.MutableStateFlow
21 +import kotlinx.coroutines.flow.StateFlow
22 +import kotlinx.coroutines.flow.asStateFlow
23 +import kotlinx.coroutines.launch
24 +import kotlinx.coroutines.withContext
25 +
26 +/** Toggle « ressource » (salle/matériel/véhicule) affiché en Paramètres, dérivé du catalogue serveur + liaisons locales. */
27 +data class RessourceToggleState(
28 + val kind: String,
29 + val serverResourceId: String,
30 + val nom: String,
31 + val displayName: String,
32 + val actif: Boolean,
33 + val linked: Boolean,
34 +)
35 +
36 +/** Liaison calendrier en attente de confirmation avant retrait (voir [SettingsViewModel.confirmRemoveBinding]). */
37 +data class PendingCalendarRemoval(
38 + val kind: String,
39 + val serverResourceId: String?,
40 + val displayName: String,
41 +)
42 +
43 +/** État de l'écran Paramètres : connecté, ou formulaire de login (+ retape en attente). */
44 +sealed interface SettingsUiState {
45 + data class LoggedIn(
46 + val userName: String,
47 + val baseUrl: String,
48 + val calendarPermissionGranted: Boolean = false,
49 + val mesRdvLinked: Boolean = false,
50 + val ressources: List<RessourceToggleState> = emptyList(),
51 + val catalogueLoading: Boolean = false,
52 + val catalogueError: String? = null,
53 + val pendingRemoval: PendingCalendarRemoval? = null,
54 + ) : SettingsUiState
55 +
56 + data class LoggedOut(
57 + val baseUrl: String = "",
58 + val userName: String = "",
59 + val password: String = "",
60 + /** Si true, accepte aussi `http://` (dev / interne / démo). Défaut false = HTTPS only. */
61 + val allowCleartextHttp: Boolean = false,
62 + val loading: Boolean = false,
63 + val error: String? = null,
64 + val pendingAuth: AuthCleResponse? = null,
65 + val retypePassword: String = "",
66 + val retypeError: String? = null,
67 + ) : SettingsUiState
68 +}
69 +
70 +/**
71 + * Login clé API : [obtainKey] appelle `/api/auth/cle`, [confirmRetype] déchiffre et stocke.
72 + * Une fois connecté, gère aussi la section Calendriers : permissions, liaison « Mes RDV » et
73 + * ressources actives (catalogue serveur), avec confirmation avant retrait d'une liaison.
74 + */
75 +class SettingsViewModel(
76 + private val credentialsStore: SyncCredentialsStore,
77 + private val calendarBindingsStore: CalendarBindingsStore,
78 + private val calendarBridge: CalendarBridgeApi,
79 + private val apiFactory: (String) -> AilianceApi = { baseUrl -> AilianceApiClient(baseUrl) },
80 + private val authenticatedApiFactory: (String, String) -> AilianceApi =
81 + { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) },
82 + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
83 +) : ViewModel() {
84 +
85 + private val _state = MutableStateFlow(initialState())
86 + val state: StateFlow<SettingsUiState> = _state.asStateFlow()
87 +
88 + private fun initialState(): SettingsUiState {
89 + val baseUrl = credentialsStore.baseUrl
90 + val userName = credentialsStore.userName
91 + return if (credentialsStore.isConfigured() && baseUrl != null && userName != null) {
92 + SettingsUiState.LoggedIn(
93 + userName = userName,
94 + baseUrl = baseUrl,
95 + mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
96 + )
97 + } else {
98 + SettingsUiState.LoggedOut(
99 + baseUrl = baseUrl.orEmpty(),
100 + allowCleartextHttp = credentialsStore.allowCleartextHttp,
101 + )
102 + }
103 + }
104 +
105 + fun setBaseUrl(value: String) = updateLoggedOut { it.copy(baseUrl = value, error = null) }
106 +
107 + fun setUserName(value: String) = updateLoggedOut { it.copy(userName = value, error = null) }
108 +
109 + fun setPassword(value: String) = updateLoggedOut { it.copy(password = value, error = null) }
110 +
111 + fun setRetypePassword(value: String) = updateLoggedOut { it.copy(retypePassword = value, retypeError = null) }
112 +
113 + fun setAllowCleartextHttp(value: Boolean) {
114 + credentialsStore.allowCleartextHttp = value
115 + updateLoggedOut { it.copy(allowCleartextHttp = value, error = null) }
116 + }
117 +
118 + fun obtainKey() {
119 + val s = _state.value
120 + if (s !is SettingsUiState.LoggedOut) return
121 + val baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
122 + val userName = s.userName.trim()
123 + if (baseUrl.isBlank() || userName.isBlank() || s.password.isBlank()) {
124 + _state.value = s.copy(error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe")
125 + return
126 + }
127 + ServerUrlPolicy.validate(baseUrl, s.allowCleartextHttp)?.let { err ->
128 + _state.value = s.copy(error = err)
129 + return
130 + }
131 + viewModelScope.launch {
132 + _state.value = s.copy(loading = true, error = null)
133 + val result = withContext(ioDispatcher) {
134 + apiFactory(baseUrl).authCle(userName, s.password)
135 + }
136 + val current = _state.value
137 + if (current !is SettingsUiState.LoggedOut) return@launch
138 + when (result) {
139 + is AilianceApiClient.ApiResult.Ok -> {
140 + _state.value = current.copy(
141 + loading = false,
142 + password = "",
143 + pendingAuth = result.value,
144 + error = null,
145 + )
146 + }
147 + is AilianceApiClient.ApiResult.Err -> {
148 + _state.value = current.copy(
149 + loading = false,
150 + password = "",
151 + error = errorMessageFor(result.code, result.message),
152 + )
153 + }
154 + }
155 + }
156 + }
157 +
158 + fun confirmRetype() {
159 + val s = _state.value
160 + if (s !is SettingsUiState.LoggedOut) return
161 + val auth = s.pendingAuth ?: return
162 + if (s.retypePassword.isBlank()) {
163 + _state.value = s.copy(retypeError = "Mot de passe requis")
164 + return
165 + }
166 + try {
167 + val apiKey = ApiCleCrypto.decryptApiKey(s.retypePassword, auth.sel, auth.cleChiffree)
168 + val baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
169 + credentialsStore.save(baseUrl = baseUrl, apiKey = apiKey, userName = auth.nom)
170 + _state.value = SettingsUiState.LoggedIn(
171 + userName = auth.nom,
172 + baseUrl = baseUrl,
173 + mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
174 + )
175 + } catch (e: ApiCleCryptoException) {
176 + _state.value = s.copy(retypePassword = "", retypeError = "Mot de passe incorrect")
177 + }
178 + }
179 +
180 + fun cancelRetype() = updateLoggedOut { it.copy(pendingAuth = null, retypePassword = "", retypeError = null) }
181 +
182 + fun disconnect() {
183 + credentialsStore.clear()
184 + _state.value = SettingsUiState.LoggedOut(
185 + allowCleartextHttp = credentialsStore.allowCleartextHttp,
186 + )
187 + }
188 +
189 + // ---- Calendriers ----
190 +
191 + /** Appelé par l'écran après la demande de permission runtime (`READ_CALENDAR`/`WRITE_CALENDAR`). */
192 + fun onCalendarPermissionChanged(granted: Boolean) {
193 + updateLoggedIn { it.copy(calendarPermissionGranted = granted) }
194 + if (granted) refreshRessourcesCatalogue()
195 + }
196 +
197 + fun setMesRdvEnabled(enabled: Boolean) {
198 + val s = _state.value
199 + if (s !is SettingsUiState.LoggedIn) return
200 + if (enabled) {
201 + viewModelScope.launch {
202 + val calendarId = withContext(ioDispatcher) { calendarBridge.ensureLocalCalendar(MES_RDV_DISPLAY_NAME) }
203 + calendarBindingsStore.upsert(
204 + CalendarBinding(
205 + kind = AgendaSyncCoordinator.KIND_RDV,
206 + displayName = MES_RDV_DISPLAY_NAME,
207 + androidCalendarId = calendarId,
208 + ),
209 + )
210 + updateLoggedIn { it.copy(mesRdvLinked = true) }
211 + }
212 + } else {
213 + updateLoggedIn {
214 + it.copy(
215 + pendingRemoval = PendingCalendarRemoval(
216 + kind = AgendaSyncCoordinator.KIND_RDV,
217 + serverResourceId = null,
218 + displayName = MES_RDV_DISPLAY_NAME,
219 + ),
220 + )
221 + }
222 + }
223 + }
224 +
225 + fun setRessourceEnabled(kind: String, serverResourceId: String, enabled: Boolean) {
226 + val s = _state.value
227 + if (s !is SettingsUiState.LoggedIn) return
228 + val item = s.ressources.firstOrNull { it.kind == kind && it.serverResourceId == serverResourceId } ?: return
229 + if (!item.actif) return
230 + if (enabled) {
231 + viewModelScope.launch {
232 + val calendarId = withContext(ioDispatcher) { calendarBridge.ensureLocalCalendar(item.displayName) }
233 + calendarBindingsStore.upsert(
234 + CalendarBinding(
235 + kind = kind,
236 + serverResourceId = serverResourceId,
237 + displayName = item.displayName,
238 + androidCalendarId = calendarId,
239 + ),
240 + )
241 + updateLoggedIn { current -> current.copy(ressources = current.ressources.map { it.withLinked(kind, serverResourceId, true) }) }
242 + }
243 + } else {
244 + updateLoggedIn { it.copy(pendingRemoval = PendingCalendarRemoval(kind, serverResourceId, item.displayName)) }
245 + }
246 + }
247 +
248 + /**
249 + * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation). Se limite à la liaison
250 + * ([CalendarBindingsStore.remove]) : [CalendarBridgeApi] n'expose pas de suppression de calendrier
251 + * Android, le calendrier local créé reste donc visible (vide) dans l'app Agenda système.
252 + */
253 + fun confirmRemoveBinding() {
254 + val s = _state.value
255 + if (s !is SettingsUiState.LoggedIn) return
256 + val pending = s.pendingRemoval ?: return
257 + calendarBindingsStore.remove(pending.kind, pending.serverResourceId)
258 + updateLoggedIn { current ->
259 + val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) {
260 + current.copy(mesRdvLinked = false)
261 + } else {
262 + current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) })
263 + }
264 + unlinked.copy(pendingRemoval = null)
265 + }
266 + }
267 +
268 + fun dismissRemoveBinding() = updateLoggedIn { it.copy(pendingRemoval = null) }
269 +
270 + private fun RessourceToggleState.withLinked(kind: String, serverResourceId: String?, linked: Boolean): RessourceToggleState =
271 + if (this.kind == kind && this.serverResourceId == serverResourceId) copy(linked = linked) else this
272 +
273 + private fun refreshRessourcesCatalogue() {
274 + val s = _state.value
275 + if (s !is SettingsUiState.LoggedIn) return
276 + val baseUrl = credentialsStore.baseUrl
277 + val apiKey = credentialsStore.apiKey
278 + if (baseUrl == null || apiKey == null) return
279 + updateLoggedIn { it.copy(catalogueLoading = true, catalogueError = null) }
280 + viewModelScope.launch {
281 + val result = withContext(ioDispatcher) { authenticatedApiFactory(baseUrl, apiKey).getRessourcesCatalogue() }
282 + when (result) {
283 + is AilianceApiClient.ApiResult.Ok -> {
284 + val bindings = calendarBindingsStore.list()
285 + updateLoggedIn { it.copy(ressources = buildRessourceToggles(result.value, bindings), catalogueLoading = false) }
286 + }
287 + is AilianceApiClient.ApiResult.Err -> {
288 + updateLoggedIn { it.copy(catalogueLoading = false, catalogueError = result.message) }
289 + }
290 + }
291 + }
292 + }
293 +
294 + private fun buildRessourceToggles(catalogue: RessourcesCatalogueDto, bindings: List<CalendarBinding>): List<RessourceToggleState> {
295 + fun toggles(kind: String, items: List<RessourceItemDto>, label: String) = items.map { item ->
296 + RessourceToggleState(
297 + kind = kind,
298 + serverResourceId = item.id,
299 + nom = item.nom,
300 + displayName = "PicLead — $label ${item.nom}",
301 + actif = item.actif,
302 + linked = bindings.any { it.kind == kind && it.serverResourceId == item.id },
303 + )
304 + }
305 + return toggles(KIND_SALLE, catalogue.salles, "Salle") +
306 + toggles(KIND_MATERIEL, catalogue.materiels, "Matériel") +
307 + toggles(KIND_VEHICULE, catalogue.vehicules, "Véhicule")
308 + }
309 +
310 + private inline fun updateLoggedOut(transform: (SettingsUiState.LoggedOut) -> SettingsUiState.LoggedOut) {
311 + val s = _state.value
312 + if (s is SettingsUiState.LoggedOut) _state.value = transform(s)
313 + }
314 +
315 + private inline fun updateLoggedIn(transform: (SettingsUiState.LoggedIn) -> SettingsUiState.LoggedIn) {
316 + val s = _state.value
317 + if (s is SettingsUiState.LoggedIn) _state.value = transform(s)
318 + }
319 +
320 + private fun errorMessageFor(code: Int, message: String): String = when (code) {
321 + 401 -> "Identifiants invalides"
322 + 404 -> "Aucune clé API pour cet utilisateur"
323 + -1 -> "Erreur réseau : $message"
324 + else -> message
325 + }
326 +
327 + companion object {
328 + const val KIND_SALLE = "salle"
329 + const val KIND_MATERIEL = "materiel"
330 + const val KIND_VEHICULE = "vehicule"
331 + const val MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV"
332 + }
333 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+103 -0
@@ -0,0 +1,103 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.Row
6 +import androidx.compose.foundation.layout.fillMaxWidth
7 +import androidx.compose.foundation.layout.padding
8 +import androidx.compose.foundation.shape.RoundedCornerShape
9 +import androidx.compose.material3.AlertDialog
10 +import androidx.compose.material3.HorizontalDivider
11 +import androidx.compose.material3.Text
12 +import androidx.compose.material3.TextButton
13 +import androidx.compose.runtime.Composable
14 +import androidx.compose.ui.Alignment
15 +import androidx.compose.ui.Modifier
16 +import androidx.compose.ui.res.stringResource
17 +import androidx.compose.ui.unit.dp
18 +import fr.ebii.card2vcf.R
19 +import fr.ebii.card2vcf.sync.AgendaConflict
20 +import fr.ebii.card2vcf.ui.theme.Bordure
21 +import fr.ebii.card2vcf.ui.theme.Fond
22 +import fr.ebii.card2vcf.ui.theme.Ink
23 +
24 +private val Square = RoundedCornerShape(0.dp)
25 +
26 +/**
27 + * Bandeaux hairline empilés : A) changements sur le serveur (ou erreur) ; B) calendrier local en
28 + * avance ([localAhead] > 0, events/entités pas encore poussés). Masqués si rien à signaler.
29 + */
30 +@Composable
31 +fun SyncBanner(
32 + pendingChanges: Int?,
33 + localAhead: Int,
34 + syncing: Boolean,
35 + error: String?,
36 + onSyncClick: () -> Unit,
37 + modifier: Modifier = Modifier,
38 +) {
39 + val remoteLabel = when {
40 + error != null -> error
41 + pendingChanges != null && pendingChanges > 0 -> stringResource(R.string.sync_pending, pendingChanges)
42 + else -> null
43 + }
44 + if (remoteLabel == null && localAhead <= 0) return
45 +
46 + Column(modifier.fillMaxWidth()) {
47 + if (remoteLabel != null) {
48 + SyncBannerRow(label = remoteLabel, syncing = syncing, onSyncClick = onSyncClick)
49 + }
50 + if (localAhead > 0) {
51 + SyncBannerRow(
52 + label = stringResource(R.string.sync_local_en_avance),
53 + syncing = syncing,
54 + onSyncClick = onSyncClick,
55 + )
56 + }
57 + }
58 +}
59 +
60 +@Composable
61 +private fun SyncBannerRow(label: String, syncing: Boolean, onSyncClick: () -> Unit) {
62 + Row(
63 + Modifier.fillMaxWidth().padding(vertical = 8.dp),
64 + verticalAlignment = Alignment.CenterVertically,
65 + horizontalArrangement = Arrangement.SpaceBetween,
66 + ) {
67 + Text(label, color = Ink, modifier = Modifier.weight(1f))
68 + TextButton(onClick = onSyncClick, enabled = !syncing, shape = Square) {
69 + Text(
70 + if (syncing) stringResource(R.string.sync_en_cours) else stringResource(R.string.sync_bouton),
71 + color = Ink,
72 + )
73 + }
74 + }
75 + HorizontalDivider(color = Bordure, thickness = 1.dp)
76 +}
77 +
78 +/** Dialog « Annuler ma réservation ? » affiché après un [SyncChromeViewModel.syncNow] en conflit (409). */
79 +@Composable
80 +fun SyncConflictDialog(
81 + conflict: AgendaConflict?,
82 + onConfirm: () -> Unit,
83 + onDismiss: () -> Unit,
84 +) {
85 + if (conflict == null) return
86 + AlertDialog(
87 + onDismissRequest = onDismiss,
88 + shape = Square,
89 + title = { Text(stringResource(R.string.sync_conflit_titre), color = Ink) },
90 + text = { Text(stringResource(R.string.sync_conflit_message), color = Ink) },
91 + confirmButton = {
92 + TextButton(onClick = onConfirm, shape = Square) {
93 + Text(stringResource(R.string.sync_conflit_oui), color = Ink)
94 + }
95 + },
96 + dismissButton = {
97 + TextButton(onClick = onDismiss, shape = Square) {
98 + Text(stringResource(R.string.sync_conflit_non), color = Ink)
99 + }
100 + },
101 + containerColor = Fond,
102 + )
103 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModel.kt
+104 -0
@@ -0,0 +1,104 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.AgendaConflict
6 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
7 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
8 +import fr.ebii.card2vcf.sync.CalendarBridgeApi
9 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
10 +import fr.ebii.card2vcf.sync.SyncEngine
11 +import kotlinx.coroutines.flow.MutableStateFlow
12 +import kotlinx.coroutines.flow.StateFlow
13 +import kotlinx.coroutines.flow.asStateFlow
14 +import kotlinx.coroutines.launch
15 +
16 +/**
17 + * État bandeau sync : changements serveur + calendrier local en avance à l'ouverture, sync manuelle
18 + * à la demande. Après [syncNow], une réservation en conflit (409) déclenche le dialog
19 + * « Annuler ma réservation ? » ([conflictPending]) — une décision à la fois si plusieurs conflits.
20 + */
21 +class SyncChromeViewModel(
22 + private val credentialsStore: SyncCredentialsStore,
23 + private val calendarBindingsStore: CalendarBindingsStore,
24 + private val calendarBridge: CalendarBridgeApi?,
25 + private val engineFactory: () -> SyncEngine?,
26 +) : ViewModel() {
27 +
28 + private val _configured = MutableStateFlow(credentialsStore.isConfigured())
29 + val configured: StateFlow<Boolean> = _configured.asStateFlow()
30 +
31 + private val _pendingRemoteChanges = MutableStateFlow<Int?>(null)
32 + val pendingRemoteChanges: StateFlow<Int?> = _pendingRemoteChanges.asStateFlow()
33 +
34 + private val _localCalendarAhead = MutableStateFlow(0)
35 + val localCalendarAhead: StateFlow<Int> = _localCalendarAhead.asStateFlow()
36 +
37 + private val _syncing = MutableStateFlow(false)
38 + val syncing: StateFlow<Boolean> = _syncing.asStateFlow()
39 +
40 + private val _error = MutableStateFlow<String?>(null)
41 + val error: StateFlow<String?> = _error.asStateFlow()
42 +
43 + private val pendingConflicts = ArrayDeque<AgendaConflict>()
44 + private val _conflictPending = MutableStateFlow<AgendaConflict?>(null)
45 + val conflictPending: StateFlow<AgendaConflict?> = _conflictPending.asStateFlow()
46 +
47 + fun refreshStatus() {
48 + _configured.value = credentialsStore.isConfigured()
49 + val engine = engineFactory() ?: return
50 + viewModelScope.launch {
51 + val bindings = calendarBindingsStore.list()
52 + val ressourcesQuery = AgendaSyncCoordinator.ressourcesQuery(bindings)
53 + val result = engine.checkStatus(ressourcesQuery)
54 + _pendingRemoteChanges.value = result.pendingRemoteChanges
55 + _error.value = result.error
56 + _localCalendarAhead.value = engine.localAheadCount(bindings, calendarBridge)
57 + }
58 + }
59 +
60 + fun syncNow() {
61 + val engine = engineFactory() ?: return
62 + viewModelScope.launch {
63 + _syncing.value = true
64 + _error.value = null
65 + val bindings = calendarBindingsStore.list()
66 + val result = engine.syncNow(bindings, calendarBridge)
67 + if (result.success) {
68 + setPendingConflicts(result.conflicts)
69 + val status = engine.checkStatus(AgendaSyncCoordinator.ressourcesQuery(bindings))
70 + _pendingRemoteChanges.value = status.pendingRemoteChanges
71 + _error.value = status.error
72 + } else {
73 + _error.value = result.error
74 + }
75 + _localCalendarAhead.value = engine.localAheadCount(bindings, calendarBridge)
76 + _syncing.value = false
77 + }
78 + }
79 +
80 + /** Dialog conflit, « Oui » : abandonne la réservation locale (entité + SyncOp + event Agenda), passe au conflit suivant. */
81 + fun confirmConflictCancellation() {
82 + val conflict = _conflictPending.value ?: return
83 + val engine = engineFactory() ?: return
84 + viewModelScope.launch {
85 + engine.abandonLocalReservation(conflict.localReservationId, calendarBridge)
86 + advanceConflictQueue()
87 + _localCalendarAhead.value = engine.localAheadCount(calendarBindingsStore.list(), calendarBridge)
88 + }
89 + }
90 +
91 + /** Dialog conflit, « Non » : ne touche pas à la réservation (reste `conflictPending`, arbitrage sur le serveur web). */
92 + fun dismissConflict() = advanceConflictQueue()
93 +
94 + private fun setPendingConflicts(conflicts: List<AgendaConflict>) {
95 + pendingConflicts.clear()
96 + pendingConflicts.addAll(conflicts)
97 + _conflictPending.value = pendingConflicts.firstOrNull()
98 + }
99 +
100 + private fun advanceConflictQueue() {
101 + if (pendingConflicts.isNotEmpty()) pendingConflicts.removeFirst()
102 + _conflictPending.value = pendingConflicts.firstOrNull()
103 + }
104 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Color.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.ui.graphics.Color
4 +
5 +// Palette Card2vcf — design.md (éditorial N&B, accent link réservé au texte inline).
6 +
7 +val Ink = Color(0xFF000000)
8 +val InkSoft = Color(0xFF1A1A1A)
9 +val Body = Color(0xFF757575)
10 +val Canvas = Color(0xFFFFFFFF)
11 +val CanvasSoft = Color(0xFFF5F5F5)
12 +val Hairline = Color(0xFFE0E0E0)
13 +val Link = Color(0xFF057DBC)
14 +val OnPrimary = Color(0xFFFFFFFF)
15 +
16 +// Alias pour écrans migrés (noms historiques Luciole → tokens design.md)
17 +val Encre = Ink
18 +val Fond = Canvas
19 +val Surface = Canvas
20 +val Bordure = Hairline
21 +val TexteFaible = Body
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Theme.kt
+44 -0
@@ -0,0 +1,44 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.foundation.shape.RoundedCornerShape
4 +import androidx.compose.material3.MaterialTheme
5 +import androidx.compose.material3.Shapes
6 +import androidx.compose.material3.lightColorScheme
7 +import androidx.compose.runtime.Composable
8 +import androidx.compose.ui.unit.dp
9 +
10 +private val Card2vcfColors = lightColorScheme(
11 + primary = Ink,
12 + onPrimary = OnPrimary,
13 + primaryContainer = CanvasSoft,
14 + onPrimaryContainer = Ink,
15 + secondary = InkSoft,
16 + onSecondary = OnPrimary,
17 + background = Canvas,
18 + onBackground = Ink,
19 + surface = Canvas,
20 + onSurface = Ink,
21 + surfaceVariant = CanvasSoft,
22 + onSurfaceVariant = Body,
23 + outline = Hairline,
24 + outlineVariant = Hairline,
25 +)
26 +
27 +/** Coins carrés — design.md `{rounded.none}`. */
28 +private val Card2vcfShapes = Shapes(
29 + extraSmall = RoundedCornerShape(0.dp),
30 + small = RoundedCornerShape(0.dp),
31 + medium = RoundedCornerShape(0.dp),
32 + large = RoundedCornerShape(0.dp),
33 + extraLarge = RoundedCornerShape(0.dp),
34 +)
35 +
36 +@Composable
37 +fun Card2vcfTheme(content: @Composable () -> Unit) {
38 + MaterialTheme(
39 + colorScheme = Card2vcfColors,
40 + typography = Card2vcfTypography,
41 + shapes = Card2vcfShapes,
42 + content = content,
43 + )
44 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Type.kt
+99 -0
@@ -0,0 +1,99 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.material3.Typography
4 +import androidx.compose.ui.text.TextStyle
5 +import androidx.compose.ui.text.font.Font
6 +import androidx.compose.ui.text.font.FontFamily
7 +import androidx.compose.ui.text.font.FontWeight
8 +import androidx.compose.ui.unit.sp
9 +import fr.ebii.card2vcf.R
10 +
11 +/** Substitut WiredDisplay — titres display. */
12 +val Playfair = FontFamily(
13 + Font(R.font.playfair_display_regular, FontWeight.Normal),
14 +)
15 +
16 +/** Substitut BreveText — corps serif. */
17 +val Lora = FontFamily(
18 + Font(R.font.lora_regular, FontWeight.Normal),
19 + Font(R.font.lora_bold, FontWeight.Bold),
20 +)
21 +
22 +/** Substitut Apercu — labels, boutons, métadonnées. */
23 +val Manrope = FontFamily(
24 + Font(R.font.manrope_regular, FontWeight.Normal),
25 + Font(R.font.manrope_bold, FontWeight.Bold),
26 +)
27 +
28 +val Card2vcfTypography = Typography(
29 + displayLarge = TextStyle(
30 + fontFamily = Playfair,
31 + fontWeight = FontWeight.Normal,
32 + fontSize = 40.sp,
33 + lineHeight = 44.sp,
34 + letterSpacing = (-0.4).sp,
35 + ),
36 + displayMedium = TextStyle(
37 + fontFamily = Playfair,
38 + fontWeight = FontWeight.Normal,
39 + fontSize = 32.sp,
40 + lineHeight = 36.sp,
41 + letterSpacing = (-0.3).sp,
42 + ),
43 + headlineMedium = TextStyle(
44 + fontFamily = Playfair,
45 + fontWeight = FontWeight.Normal,
46 + fontSize = 26.sp,
47 + lineHeight = 28.sp,
48 + ),
49 + titleLarge = TextStyle(
50 + fontFamily = Playfair,
51 + fontWeight = FontWeight.Normal,
52 + fontSize = 22.sp,
53 + lineHeight = 26.sp,
54 + ),
55 + titleMedium = TextStyle(
56 + fontFamily = Manrope,
57 + fontWeight = FontWeight.Bold,
58 + fontSize = 16.sp,
59 + lineHeight = 20.sp,
60 + letterSpacing = 0.3.sp,
61 + ),
62 + bodyLarge = TextStyle(
63 + fontFamily = Lora,
64 + fontWeight = FontWeight.Normal,
65 + fontSize = 16.sp,
66 + lineHeight = 24.sp,
67 + ),
68 + bodyMedium = TextStyle(
69 + fontFamily = Manrope,
70 + fontWeight = FontWeight.Normal,
71 + fontSize = 14.sp,
72 + lineHeight = 18.sp,
73 + ),
74 + bodySmall = TextStyle(
75 + fontFamily = Manrope,
76 + fontWeight = FontWeight.Normal,
77 + fontSize = 12.sp,
78 + lineHeight = 16.sp,
79 + ),
80 + labelLarge = TextStyle(
81 + fontFamily = Manrope,
82 + fontWeight = FontWeight.Bold,
83 + fontSize = 16.sp,
84 + lineHeight = 20.sp,
85 + letterSpacing = 0.3.sp,
86 + ),
87 + labelMedium = TextStyle(
88 + fontFamily = Manrope,
89 + fontWeight = FontWeight.Bold,
90 + fontSize = 14.sp,
91 + lineHeight = 18.sp,
92 + ),
93 + labelSmall = TextStyle(
94 + fontFamily = Manrope,
95 + fontWeight = FontWeight.Bold,
96 + fontSize = 12.sp,
97 + lineHeight = 16.sp,
98 + ),
99 +)
A android/app/src/main/res/drawable/ic_launcher_foreground.png
+0 -0
A android/app/src/main/res/font/lora_bold.ttf
+0 -0
A android/app/src/main/res/font/lora_regular.ttf
+0 -0
A android/app/src/main/res/font/manrope_bold.ttf
+0 -0
A android/app/src/main/res/font/manrope_regular.ttf
+0 -0
A android/app/src/main/res/font/playfair_display_regular.ttf
+0 -0
A android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+5 -0
@@ -0,0 +1,5 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3 + <background android:drawable="@color/ic_launcher_background"/>
4 + <foreground android:drawable="@drawable/ic_launcher_foreground"/>
5 +</adaptive-icon>
A android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+5 -0
@@ -0,0 +1,5 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3 + <background android:drawable="@color/ic_launcher_background"/>
4 + <foreground android:drawable="@drawable/ic_launcher_foreground"/>
5 +</adaptive-icon>
A android/app/src/main/res/mipmap-hdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-mdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/values-en/strings.xml
+145 -0
@@ -0,0 +1,145 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <string name="app_name">PicLead</string>
4 +
5 + <string name="scan_titre">Scan a card</string>
6 + <string name="scan_instructions">Frame the business card, then capture.</string>
7 + <string name="scan_capturer">Capture</string>
8 + <string name="scan_autoriser_camera">Allow camera</string>
9 + <string name="scan_en_cours">Correcting image…</string>
10 + <string name="scan_ocr_en_cours">Recognizing text…</string>
11 + <string name="scan_structuration">Analyzing contact…</string>
12 + <string name="scan_brouillon_titre">Review contact</string>
13 + <string name="scan_brouillon_sous_titre">Edit fields, save to the address book, or export to Contacts / VCF.</string>
14 + <string name="scan_enregistrer_carnet">Save</string>
15 + <string name="scan_ocr_brut">Raw OCR text</string>
16 + <string name="scan_creer_contact">Create contact</string>
17 + <string name="scan_exporter_vcf">Export VCF</string>
18 + <string name="scan_reprendre">Capture again</string>
19 + <string name="scan_retour">Back</string>
20 + <string name="scan_champ_nom">Full name</string>
21 + <string name="scan_champ_prenom">First name</string>
22 + <string name="scan_champ_nom_famille">Last name</string>
23 + <string name="scan_champ_societe">Company</string>
24 + <string name="scan_champ_poste">Job title</string>
25 + <string name="scan_champ_tel">Phone(s)</string>
26 + <string name="scan_champ_email">Email(s)</string>
27 + <string name="scan_champ_site">Website</string>
28 + <string name="scan_champ_adresse">Address</string>
29 + <string name="scan_champ_note">Note</string>
30 +
31 + <string name="carnet_recherche">Search…</string>
32 + <string name="carnet_tri_nom">Last name</string>
33 + <string name="carnet_tri_prenom">First name</string>
34 + <string name="carnet_tri_entreprise">Company</string>
35 + <string name="carnet_tri_date">Date</string>
36 + <string name="carnet_fab_ajouter">Other add options</string>
37 + <string name="carnet_fab_scanner">Scan a card</string>
38 + <string name="carnet_fab_manuel">Manual entry</string>
39 + <string name="carnet_fab_import">Import VCF</string>
40 + <string name="carnet_vide">No contacts — scan a card or import a VCF</string>
41 +
42 + <string name="fiche_editer">Edit</string>
43 + <string name="fiche_doublons">%d duplicate(s)</string>
44 + <string name="fiche_photo_profil">Profile photo</string>
45 + <string name="fiche_photo_carte">Card photo</string>
46 + <string name="fiche_tourner_gauche">Rotate 90° left</string>
47 +
48 + <string name="edit_titre">Edit contact</string>
49 + <string name="edit_enregistrer">Save</string>
50 + <string name="edit_mettre_a_jour">Update</string>
51 + <string name="edit_rescanner">Rescan card</string>
52 + <string name="edit_photo_profil">Profile photo</string>
53 + <string name="edit_prendre_photo">Take photo</string>
54 + <string name="edit_choisir_photo">Choose photo</string>
55 + <string name="manual_titre">New contact</string>
56 + <string name="manual_sous_titre">Fill in the fields, then save to the address book.</string>
57 +
58 + <string name="dup_revue_titre">Duplicates</string>
59 + <string name="dup_fusion_titre">Merge</string>
60 + <string name="dup_contact_courant">Current contact</string>
61 + <string name="dup_autres">Similar contacts</string>
62 + <string name="dup_aucun">No duplicates</string>
63 + <string name="dup_supprimer">Delete selection</string>
64 + <string name="dup_marquer_differents">Mark as different</string>
65 + <string name="dup_fusionner">Merge</string>
66 + <string name="dup_garder">Keep this record</string>
67 + <string name="dup_notes_mode">Notes</string>
68 + <string name="dup_notes_garder">Keep notes from the retained record</string>
69 + <string name="dup_notes_concat">Concatenate all notes</string>
70 + <string name="dup_image_aucune">None</string>
71 + <string name="dup_annuler">Cancel</string>
72 + <string name="dup_confirmer_fusion">Confirm merge</string>
73 +
74 + <string name="import_titre">Import VCF</string>
75 + <string name="import_sous_titre">Pick a vCard file (.vcf). Duplicates (email or phone) are skipped.</string>
76 + <string name="import_choisir_fichier">Choose a file</string>
77 + <string name="import_en_cours">Importing…</string>
78 + <string name="import_resume_titre">Summary</string>
79 + <string name="import_resume_importes">Imported: %d</string>
80 + <string name="import_resume_doublons">Duplicates skipped: %d</string>
81 + <string name="import_resume_erreurs">Errors: %d</string>
82 + <string name="import_retour_carnet">Back to address book</string>
83 + <string name="import_autre_fichier">Import another file</string>
84 + <string name="import_reessayer">Try again</string>
85 +
86 + <string name="settings_titre">Settings</string>
87 + <string name="settings_url_placeholder">Server URL (https://…)</string>
88 + <string name="settings_utilisateur_placeholder">Username</string>
89 + <string name="settings_mot_de_passe_placeholder">Password</string>
90 + <string name="settings_allow_cleartext">Allow cleartext HTTP</string>
91 + <string name="settings_allow_cleartext_hint">For development, internal networks, or demos only. HTTPS is still recommended.</string>
92 + <string name="settings_obtenir_cle">Get the key</string>
93 + <string name="settings_retype_titre">Retype the password to decrypt</string>
94 + <string name="settings_retype_confirmer">Confirm</string>
95 + <string name="settings_retype_annuler">Cancel</string>
96 + <string name="settings_connecte_comme">Signed in as %1$s</string>
97 + <string name="settings_cle_configuree">API key: •••• (configured)</string>
98 + <string name="settings_deconnecter">Sign out</string>
99 +
100 + <string name="settings_calendriers_titre">Calendars</string>
101 + <string name="settings_calendrier_permission_manquante">Allow calendar access to link your appointments and resources.</string>
102 + <string name="settings_calendrier_autoriser">Allow calendar</string>
103 + <string name="settings_calendrier_mes_rdv">My appointments</string>
104 + <string name="settings_calendrier_salles">Rooms</string>
105 + <string name="settings_calendrier_materiels">Equipment</string>
106 + <string name="settings_calendrier_vehicules">Vehicles</string>
107 + <string name="settings_calendrier_chargement">Loading resources…</string>
108 + <string name="settings_calendrier_ressource_inactive">inactive</string>
109 + <string name="settings_calendrier_retirer_titre">Remove this calendar?</string>
110 + <string name="settings_calendrier_retirer_message">"%1$s" will no longer sync. The calendar stays visible in the Calendar app.</string>
111 + <string name="settings_calendrier_retirer_confirmer">Remove</string>
112 + <string name="settings_calendrier_retirer_annuler">Cancel</string>
113 +
114 + <string name="sync_pending">%d change(s) on the server</string>
115 + <string name="sync_bouton">Sync</string>
116 + <string name="sync_en_cours">Syncing…</string>
117 + <string name="sync_icone">Sync</string>
118 + <string name="sync_local_en_avance">Local calendar is ahead of the server</string>
119 + <string name="sync_conflit_titre">Cancel my reservation?</string>
120 + <string name="sync_conflit_message">This reservation conflicts with another one on the server. Do you want to cancel it?</string>
121 + <string name="sync_conflit_oui">Yes, cancel</string>
122 + <string name="sync_conflit_non">No</string>
123 +
124 + <string name="onglet_carnet">Address book</string>
125 + <string name="onglet_projets">Projects</string>
126 +
127 + <string name="projets_vide">No projects — sync to fetch them</string>
128 + <string name="projet_membres">Members</string>
129 + <string name="projet_cr_titre">Report</string>
130 + <string name="projet_cr_choisir_contact">Choose a contact</string>
131 + <string name="projet_cr_sujet_placeholder">Subject</string>
132 + <string name="projet_cr_description_placeholder">Description</string>
133 + <string name="projet_cr_ajouter">Add</string>
134 +
135 + <string name="kanban_filtre_mes_taches">My tasks</string>
136 + <string name="kanban_filtre_toutes">All</string>
137 + <string name="kanban_nouvelle_tache">New task</string>
138 + <string name="kanban_editer_tache">Edit task</string>
139 + <string name="kanban_champ_titre">Title</string>
140 + <string name="kanban_non_assigne">Unassigned</string>
141 + <string name="kanban_assigne_a">Assigned: %s</string>
142 + <string name="kanban_supprimer">Delete</string>
143 + <string name="kanban_valider">Save</string>
144 + <string name="kanban_sans_workflow">No workflow linked to this project</string>
145 +</resources>
A android/app/src/main/res/values/colors.xml
+4 -0
@@ -0,0 +1,4 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <color name="ic_launcher_background">#9aceec</color>
4 +</resources>
A android/app/src/main/res/values/strings.xml
+145 -0
@@ -0,0 +1,145 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <string name="app_name">PicLead</string>
4 +
5 + <string name="scan_titre">Scanner une carte</string>
6 + <string name="scan_instructions">Cadrez la carte de visite, puis capturez.</string>
7 + <string name="scan_capturer">Capturer</string>
8 + <string name="scan_autoriser_camera">Autoriser la caméra</string>
9 + <string name="scan_en_cours">Correction de l\'image…</string>
10 + <string name="scan_ocr_en_cours">Reconnaissance du texte…</string>
11 + <string name="scan_structuration">Analyse du contact…</string>
12 + <string name="scan_brouillon_titre">Vérifier le contact</string>
13 + <string name="scan_brouillon_sous_titre">Corrigez les champs, enregistrez dans le carnet, ou exportez vers Contacts / VCF.</string>
14 + <string name="scan_enregistrer_carnet">Enregistrer</string>
15 + <string name="scan_ocr_brut">Texte OCR brut</string>
16 + <string name="scan_creer_contact">Créer le contact</string>
17 + <string name="scan_exporter_vcf">Exporter VCF</string>
18 + <string name="scan_reprendre">Reprendre la capture</string>
19 + <string name="scan_retour">Retour</string>
20 + <string name="scan_champ_nom">Nom complet</string>
21 + <string name="scan_champ_prenom">Prénom</string>
22 + <string name="scan_champ_nom_famille">Nom de famille</string>
23 + <string name="scan_champ_societe">Société</string>
24 + <string name="scan_champ_poste">Poste</string>
25 + <string name="scan_champ_tel">Téléphone(s)</string>
26 + <string name="scan_champ_email">E-mail(s)</string>
27 + <string name="scan_champ_site">Site web</string>
28 + <string name="scan_champ_adresse">Adresse</string>
29 + <string name="scan_champ_note">Note</string>
30 +
31 + <string name="carnet_recherche">Rechercher…</string>
32 + <string name="carnet_tri_nom">Nom</string>
33 + <string name="carnet_tri_prenom">Prénom</string>
34 + <string name="carnet_tri_entreprise">Entreprise</string>
35 + <string name="carnet_tri_date">Date</string>
36 + <string name="carnet_fab_ajouter">Autres ajouts</string>
37 + <string name="carnet_fab_scanner">Scanner une carte</string>
38 + <string name="carnet_fab_manuel">Saisie manuelle</string>
39 + <string name="carnet_fab_import">Import VCF</string>
40 + <string name="carnet_vide">Aucun contact — scanner une carte ou importer un VCF</string>
41 +
42 + <string name="fiche_editer">Éditer</string>
43 + <string name="fiche_doublons">%d doublon(s)</string>
44 + <string name="fiche_photo_profil">Photo de profil</string>
45 + <string name="fiche_photo_carte">Photo de la carte</string>
46 + <string name="fiche_tourner_gauche">Tourner 90° à gauche</string>
47 +
48 + <string name="edit_titre">Modifier le contact</string>
49 + <string name="edit_enregistrer">Enregistrer</string>
50 + <string name="edit_mettre_a_jour">Mettre à jour</string>
51 + <string name="edit_rescanner">Rescanner la carte</string>
52 + <string name="edit_photo_profil">Photo de profil</string>
53 + <string name="edit_prendre_photo">Prendre une photo</string>
54 + <string name="edit_choisir_photo">Choisir une photo</string>
55 + <string name="manual_titre">Nouveau contact</string>
56 + <string name="manual_sous_titre">Saisissez les champs, puis enregistrez dans le carnet.</string>
57 +
58 + <string name="dup_revue_titre">Doublons</string>
59 + <string name="dup_fusion_titre">Fusionner</string>
60 + <string name="dup_contact_courant">Contact courant</string>
61 + <string name="dup_autres">Contacts similaires</string>
62 + <string name="dup_aucun">Aucun doublon</string>
63 + <string name="dup_supprimer">Supprimer la sélection</string>
64 + <string name="dup_marquer_differents">Contacts différents</string>
65 + <string name="dup_fusionner">Fusionner</string>
66 + <string name="dup_garder">Conserver la fiche</string>
67 + <string name="dup_notes_mode">Notes</string>
68 + <string name="dup_notes_garder">Garder celles de la fiche conservée</string>
69 + <string name="dup_notes_concat">Concaténer toutes les notes</string>
70 + <string name="dup_image_aucune">Aucune</string>
71 + <string name="dup_annuler">Annuler</string>
72 + <string name="dup_confirmer_fusion">Confirmer la fusion</string>
73 +
74 + <string name="import_titre">Import VCF</string>
75 + <string name="import_sous_titre">Choisissez un fichier vCard (.vcf). Les doublons (e-mail ou téléphone) sont ignorés.</string>
76 + <string name="import_choisir_fichier">Choisir un fichier</string>
77 + <string name="import_en_cours">Import en cours…</string>
78 + <string name="import_resume_titre">Résumé</string>
79 + <string name="import_resume_importes">Importés : %d</string>
80 + <string name="import_resume_doublons">Doublons ignorés : %d</string>
81 + <string name="import_resume_erreurs">Erreurs : %d</string>
82 + <string name="import_retour_carnet">Retour au carnet</string>
83 + <string name="import_autre_fichier">Importer un autre fichier</string>
84 + <string name="import_reessayer">Réessayer</string>
85 +
86 + <string name="settings_titre">Paramètres</string>
87 + <string name="settings_url_placeholder">URL du serveur (https://…)</string>
88 + <string name="settings_utilisateur_placeholder">Nom d\'utilisateur</string>
89 + <string name="settings_mot_de_passe_placeholder">Mot de passe</string>
90 + <string name="settings_allow_cleartext">Autoriser HTTP (clair)</string>
91 + <string name="settings_allow_cleartext_hint">Uniquement pour le développement, le réseau interne ou une démo. HTTPS reste recommandé.</string>
92 + <string name="settings_obtenir_cle">Obtenir la clé</string>
93 + <string name="settings_retype_titre">Retapez le mot de passe pour déchiffrer</string>
94 + <string name="settings_retype_confirmer">Confirmer</string>
95 + <string name="settings_retype_annuler">Annuler</string>
96 + <string name="settings_connecte_comme">Connecté comme %1$s</string>
97 + <string name="settings_cle_configuree">Clé API : •••• (configurée)</string>
98 + <string name="settings_deconnecter">Déconnecter</string>
99 +
100 + <string name="settings_calendriers_titre">Calendriers</string>
101 + <string name="settings_calendrier_permission_manquante">Autorisez l\'accès à l\'agenda pour lier vos rendez-vous et vos ressources.</string>
102 + <string name="settings_calendrier_autoriser">Autoriser l\'agenda</string>
103 + <string name="settings_calendrier_mes_rdv">Mes RDV</string>
104 + <string name="settings_calendrier_salles">Salles</string>
105 + <string name="settings_calendrier_materiels">Matériel</string>
106 + <string name="settings_calendrier_vehicules">Véhicules</string>
107 + <string name="settings_calendrier_chargement">Chargement des ressources…</string>
108 + <string name="settings_calendrier_ressource_inactive">inactive</string>
109 + <string name="settings_calendrier_retirer_titre">Retirer ce calendrier ?</string>
110 + <string name="settings_calendrier_retirer_message">« %1$s » ne sera plus synchronisé. Le calendrier reste visible dans l\'app Agenda.</string>
111 + <string name="settings_calendrier_retirer_confirmer">Retirer</string>
112 + <string name="settings_calendrier_retirer_annuler">Annuler</string>
113 +
114 + <string name="sync_pending">%d changement(s) sur le serveur</string>
115 + <string name="sync_bouton">Synchroniser</string>
116 + <string name="sync_en_cours">Synchronisation…</string>
117 + <string name="sync_icone">Synchroniser</string>
118 + <string name="sync_local_en_avance">Calendrier local en avance sur le serveur</string>
119 + <string name="sync_conflit_titre">Annuler ma réservation ?</string>
120 + <string name="sync_conflit_message">Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l\'annuler ?</string>
121 + <string name="sync_conflit_oui">Oui, annuler</string>
122 + <string name="sync_conflit_non">Non</string>
123 +
124 + <string name="onglet_carnet">Carnet</string>
125 + <string name="onglet_projets">Projets</string>
126 +
127 + <string name="projets_vide">Aucun projet — synchronisez pour les récupérer</string>
128 + <string name="projet_membres">Membres</string>
129 + <string name="projet_cr_titre">Compte-rendu</string>
130 + <string name="projet_cr_choisir_contact">Choisir un contact</string>
131 + <string name="projet_cr_sujet_placeholder">Sujet</string>
132 + <string name="projet_cr_description_placeholder">Description</string>
133 + <string name="projet_cr_ajouter">Ajouter</string>
134 +
135 + <string name="kanban_filtre_mes_taches">Mes tâches</string>
136 + <string name="kanban_filtre_toutes">Toutes</string>
137 + <string name="kanban_nouvelle_tache">Nouvelle tâche</string>
138 + <string name="kanban_editer_tache">Modifier la tâche</string>
139 + <string name="kanban_champ_titre">Titre</string>
140 + <string name="kanban_non_assigne">Non assigné</string>
141 + <string name="kanban_assigne_a">Assigné : %s</string>
142 + <string name="kanban_supprimer">Supprimer</string>
143 + <string name="kanban_valider">Valider</string>
144 + <string name="kanban_sans_workflow">Aucun workflow associé à ce projet</string>
145 +</resources>
A android/app/src/main/res/xml/file_paths.xml
+5 -0
@@ -0,0 +1,5 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<paths>
3 + <cache-path name="vcf" path="vcf/" />
4 + <cache-path name="images" path="images/" />
5 +</paths>
A android/app/src/main/res/xml/network_security_config.xml
+9 -0
@@ -0,0 +1,9 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!--
3 + Cleartext autorisé au niveau plateforme pour que http:// fonctionne
4 + quand l'utilisateur coche « Autoriser HTTP (clair) » en Paramètres.
5 + La politique applicative (ServerUrlPolicy) refuse http:// par défaut.
6 +-->
7 +<network-security-config>
8 + <base-config cleartextTrafficPermitted="true" />
9 +</network-security-config>
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactCardTest.kt
+37 -0
@@ -0,0 +1,37 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertFalse
6 +import kotlin.test.assertTrue
7 +
8 +class ContactCardTest {
9 + @Test fun champsPartiels() {
10 + val c = ContactCard(phones = listOf("+33612345678"))
11 + assertEquals("", c.displayName())
12 + assertTrue(c.hasAnyField())
13 + }
14 +
15 + @Test fun displayNamePrioriseFullName() {
16 + val c = ContactCard(fullName = "Jean Dupont", firstName = "Jean", lastName = "Dupont")
17 + assertEquals("Jean Dupont", c.displayName())
18 + }
19 +
20 + @Test fun displayNameDepuisPrenomNom() {
21 + val c = ContactCard(firstName = "Jean", lastName = "Dupont")
22 + assertEquals("Jean Dupont", c.displayName())
23 + }
24 +
25 + @Test fun listesMultiples() {
26 + val c = ContactCard(
27 + phones = listOf("0612345678", "0145678901"),
28 + emails = listOf("a@b.fr", "c@d.fr"),
29 + )
30 + assertEquals(2, c.phones.size)
31 + assertEquals(2, c.emails.size)
32 + }
33 +
34 + @Test fun videSansChamp() {
35 + assertFalse(ContactCard().hasAnyField())
36 + }
37 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactDraftMergeTest.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +
7 +class ContactDraftMergeTest {
8 + @Test fun fusionneRegexEtLlm() {
9 + val llm = ContactCard(fullName = "Jean Dupont", phones = listOf("0612345678"))
10 + val ocr = OcrResult(
11 + rawText = "Jean Dupont\n0612345678\njean@acme.fr",
12 + phones = listOf("0612345678", "0145678901"),
13 + emails = listOf("jean@acme.fr"),
14 + urls = listOf("https://acme.fr"),
15 + )
16 + val merged = ContactDraftMerge.merge(llm, ocr)
17 + assertEquals("Jean Dupont", merged.fullName)
18 + assertEquals(listOf("0612345678", "0145678901"), merged.phones)
19 + assertEquals(listOf("jean@acme.fr"), merged.emails)
20 + assertEquals("https://acme.fr", merged.website)
21 + }
22 +
23 + @Test fun fallbackRegexSiLlmNull() {
24 + val ocr = OcrResult(
25 + rawText = "x",
26 + phones = listOf("0611111111"),
27 + emails = listOf("a@b.fr"),
28 + urls = listOf("https://x.fr"),
29 + )
30 + val merged = ContactDraftMerge.merge(null, ocr)
31 + assertEquals(listOf("0611111111"), merged.phones)
32 + assertEquals("https://x.fr", merged.website)
33 + }
34 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactHeuristicParserTest.kt
+69 -0
@@ -0,0 +1,69 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrPostProcessor
4 +import fr.ebii.card2vcf.ocr.OcrResult
5 +import kotlin.test.Test
6 +import kotlin.test.assertEquals
7 +import kotlin.test.assertNotNull
8 +import kotlin.test.assertTrue
9 +
10 +class ContactHeuristicParserTest {
11 +
12 + @Test fun parseCarteDimoBruitee() {
13 + val text = """
14 + dimo
15 + SOFTWARE &—
16 + Jean Pau! GENOUX
17 + Directeur Général/CEO
18 + jpgenoux@dimosoftware.com
19 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
20 + Siège Social
21 + 561, alée des Noisetiers - 69 760 Limonest - France
22 + Lyon - Paris - Nantes - Biarritz - Madrid — Toronto
23 + www.dimosoftware.fr
24 + """.trimIndent()
25 + val ocr = OcrPostProcessor.process(text)
26 + val card = ContactHeuristicParser.parse(ocr)
27 +
28 + assertEquals("Jean Paul Genoux", card.fullName)
29 + assertEquals("Jean Paul", card.firstName)
30 + assertEquals("Genoux", card.lastName)
31 + assertTrue(card.jobTitle!!.contains("Directeur", ignoreCase = true))
32 + assertTrue(card.company!!.contains("Software", ignoreCase = true) || card.company!!.contains("dimo", ignoreCase = true))
33 + assertTrue(card.emails.contains("jpgenoux@dimosoftware.com"))
34 + assertTrue(card.phones.any { it.endsWith("674640544") })
35 + assertTrue(card.phones.any { it.contains("472860192") })
36 + assertTrue(card.website!!.contains("dimosoftware.fr"))
37 + assertNotNull(card.address)
38 + assertTrue(card.address!!.contains("Noisetiers") || card.address!!.contains("Limonest"))
39 + }
40 +
41 + @Test fun splitNomAvecNomFamilleMajuscules() {
42 + val (first, last, full) = ContactHeuristicParser.splitPersonName("Marie CURIE")
43 + assertEquals("Marie", first)
44 + assertEquals("Curie", last)
45 + assertEquals("Marie Curie", full)
46 + }
47 +
48 + @Test fun societeDepuisEmail() {
49 + assertEquals("Dimo Software", ContactHeuristicParser.companyFromEmailDomain("jp@dimosoftware.com"))
50 + }
51 +
52 + @Test fun mergeSansLlmRemplitQuandMemeLesChamps() {
53 + val ocr = OcrPostProcessor.process(
54 + """
55 + Acme SAS
56 + Alice Martin
57 + CEO
58 + alice@acme.fr
59 + 01 23 45 67 89
60 + 10 rue de Paris 75001 Paris
61 + """.trimIndent()
62 + )
63 + val merged = ContactDraftMerge.merge(null, ocr)
64 + assertEquals("Alice Martin", merged.fullName)
65 + assertTrue(merged.emails.contains("alice@acme.fr"))
66 + assertTrue(merged.jobTitle!!.contains("CEO", ignoreCase = true))
67 + assertNotNull(merged.company)
68 + }
69 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactInsertIntentTest.kt
+31 -0
@@ -0,0 +1,31 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Intent
4 +import android.provider.ContactsContract
5 +import org.junit.runner.RunWith
6 +import org.robolectric.RobolectricTestRunner
7 +import org.robolectric.annotation.Config
8 +import kotlin.test.Test
9 +import kotlin.test.assertEquals
10 +
11 +@RunWith(RobolectricTestRunner::class)
12 +@Config(sdk = [31])
13 +class ContactInsertIntentTest {
14 + @Test fun extrasPrenomNomEtTelephone() {
15 + val card = ContactCard(
16 + fullName = "Jean Dupont",
17 + company = "Acme",
18 + jobTitle = "Directeur",
19 + phones = listOf("0612345678"),
20 + emails = listOf("jean@acme.fr"),
21 + address = "Paris",
22 + note = "note test",
23 + )
24 + val intent = ContactInsertIntent.build(card)
25 + assertEquals(Intent.ACTION_INSERT, intent.action)
26 + assertEquals("Jean Dupont", intent.getStringExtra(ContactsContract.Intents.Insert.NAME))
27 + assertEquals("0612345678", intent.getStringExtra(ContactsContract.Intents.Insert.PHONE))
28 + assertEquals("jean@acme.fr", intent.getStringExtra(ContactsContract.Intents.Insert.EMAIL))
29 + assertEquals("Acme", intent.getStringExtra(ContactsContract.Intents.Insert.COMPANY))
30 + }
31 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt
+84 -0
@@ -0,0 +1,84 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class VCardParserTest {
8 +
9 + private fun loadFixture(name: String): String =
10 + checkNotNull(javaClass.classLoader!!.getResourceAsStream("vcf/$name"))
11 + .bufferedReader()
12 + .use { it.readText() }
13 +
14 + @Test
15 + fun parseSample3() {
16 + val cards = VCardParser.parse(loadFixture("sample3.vcf"))
17 + assertEquals(2, cards.size)
18 +
19 + val alice = cards[0]
20 + assertEquals("Alice Martin", alice.fullName)
21 + assertEquals("Alice", alice.firstName)
22 + assertEquals("Martin", alice.lastName)
23 + assertEquals("Acme", alice.company)
24 + assertEquals(listOf("alice@acme.test"), alice.emails)
25 + assertEquals(listOf("+33601020304"), alice.phones)
26 + assertEquals("salon Lyon", alice.note)
27 +
28 + val bob = cards[1]
29 + assertEquals("Bob", bob.fullName)
30 + assertEquals(listOf("bob@unique.test"), bob.emails)
31 + }
32 +
33 + @Test
34 + fun parseSample4() {
35 + val cards = VCardParser.parse(loadFixture("sample4.vcf"))
36 + assertEquals(1, cards.size)
37 + val alice = cards[0]
38 + assertEquals("Alice V4", alice.fullName)
39 + assertEquals(listOf("alice@acme.test"), alice.emails)
40 + assertEquals(listOf("+33601020304"), alice.phones)
41 + }
42 +
43 + @Test
44 + fun unfoldFoldedLine() {
45 + // Leading space/tab of continuation is the fold marker (dropped); extra space stays in value.
46 + val folded = "BEGIN:VCARD\n" +
47 + "VERSION:3.0\n" +
48 + "FN:Alice\n" +
49 + " Martin\n" +
50 + "NOTE:hello\n" +
51 + "\t world\n" +
52 + "END:VCARD"
53 + val cards = VCardParser.parse(folded)
54 + assertEquals(1, cards.size)
55 + assertEquals("Alice Martin", cards[0].fullName)
56 + assertEquals("hello world", cards[0].note)
57 + }
58 +
59 + @Test
60 + fun ignorePhoto() {
61 + val text = """
62 + BEGIN:VCARD
63 + VERSION:3.0
64 + FN:Photo Person
65 + PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ
66 + EMAIL:photo@test.example
67 + END:VCARD
68 + """.trimIndent()
69 + val cards = VCardParser.parse(text)
70 + assertEquals(1, cards.size)
71 + assertEquals("Photo Person", cards[0].fullName)
72 + assertEquals(listOf("photo@test.example"), cards[0].emails)
73 + assertTrue(cards[0].note == null)
74 + }
75 +
76 + @Test
77 + fun exportRoundtripVersion30() {
78 + val cards = VCardParser.parse(loadFixture("sample3.vcf"))
79 + val vcf = VCardSerializer.toVCard3(cards[0])
80 + assertTrue(vcf.contains("VERSION:3.0"))
81 + assertTrue(vcf.contains("FN:Alice Martin"))
82 + assertTrue(vcf.contains("ORG:Acme"))
83 + }
84 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/VCardSerializerTest.kt
+43 -0
@@ -0,0 +1,43 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class VCardSerializerTest {
8 + @Test fun vcardMinimal() {
9 + val vcf = VCardSerializer.toVCard3(ContactCard(fullName = "Jean Dupont"))
10 + assertTrue(vcf.contains("BEGIN:VCARD"))
11 + assertTrue(vcf.contains("FN:Jean Dupont"))
12 + assertTrue(vcf.contains("END:VCARD"))
13 + }
14 +
15 + @Test fun accentsEtEchappement() {
16 + val vcf = VCardSerializer.toVCard3(
17 + ContactCard(
18 + fullName = "Élodie Martin",
19 + note = "Ligne1\nLigne2",
20 + company = "Société; Générale",
21 + )
22 + )
23 + assertTrue(vcf.contains("FN:Élodie Martin"))
24 + assertTrue(vcf.contains("NOTE:Ligne1\\nLigne2"))
25 + assertTrue(vcf.contains("ORG:Société\\; Générale"))
26 + }
27 +
28 + @Test fun multiTelEtEmail() {
29 + val vcf = VCardSerializer.toVCard3(
30 + ContactCard(
31 + fullName = "Test",
32 + phones = listOf("0612345678", "0145678901"),
33 + emails = listOf("a@b.fr", "c@d.fr"),
34 + )
35 + )
36 + assertEquals(2, vcf.split('\n').count { it.startsWith("TEL;") })
37 + assertEquals(2, vcf.split('\n').count { it.startsWith("EMAIL;") })
38 + }
39 +
40 + @Test fun escapeUnitaire() {
41 + assertEquals("a\\;b", VCardSerializer.escape("a;b"))
42 + }
43 +}
A android/app/src/test/java/fr/ebii/card2vcf/crm/AlphabetIndexTest.kt
+72 -0
@@ -0,0 +1,72 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +
7 +class AlphabetIndexTest {
8 + private fun contact(
9 + id: Long,
10 + lastName: String? = null,
11 + firstName: String? = null,
12 + fullName: String? = null,
13 + company: String? = null,
14 + createdAt: Long = 0L,
15 + ) = CrmContactEntity(
16 + id = id,
17 + lastName = lastName,
18 + firstName = firstName,
19 + fullName = fullName,
20 + company = company,
21 + createdAt = createdAt,
22 + )
23 +
24 + @Test
25 + fun sectionLetterAzAndHash() {
26 + assertEquals('A', AlphabetIndex.sectionLetter("alice"))
27 + assertEquals('Z', AlphabetIndex.sectionLetter(" zebra"))
28 + assertEquals('#', AlphabetIndex.sectionLetter(""))
29 + assertEquals('#', AlphabetIndex.sectionLetter(" "))
30 + assertEquals('#', AlphabetIndex.sectionLetter("Émile"))
31 + assertEquals('#', AlphabetIndex.sectionLetter("9ops"))
32 + }
33 +
34 + @Test
35 + fun sortKeyFallbacks() {
36 + val withLast = contact(1, lastName = "Dupont", fullName = "Jean Dupont")
37 + val onlyFull = contact(2, fullName = "Solo")
38 + assertEquals("Dupont", AlphabetIndex.sortKey(withLast, ContactSort.LAST_NAME))
39 + assertEquals("Solo", AlphabetIndex.sortKey(onlyFull, ContactSort.LAST_NAME))
40 + assertEquals("Solo", AlphabetIndex.sortKey(onlyFull, ContactSort.FIRST_NAME))
41 + assertEquals("", AlphabetIndex.sortKey(contact(3), ContactSort.COMPANY))
42 + assertEquals("00000000000000000042", AlphabetIndex.sortKey(contact(4, createdAt = 42), ContactSort.CREATED_AT))
43 + }
44 +
45 + @Test
46 + fun groupByLastNameSections() {
47 + val contacts = listOf(
48 + contact(1, lastName = "Bernard"),
49 + contact(2, lastName = "Adams"),
50 + contact(3, lastName = "Dupont"),
51 + contact(4, lastName = "123"),
52 + )
53 + val grouped = AlphabetIndex.group(contacts, ContactSort.LAST_NAME)
54 + assertEquals(listOf('A', 'B', 'D', '#'), grouped.keys.toList())
55 + assertEquals(listOf(2L), grouped['A']!!.map { it.id })
56 + assertEquals(listOf(1L), grouped['B']!!.map { it.id })
57 + assertEquals(listOf(3L), grouped['D']!!.map { it.id })
58 + assertEquals(listOf(4L), grouped['#']!!.map { it.id })
59 + }
60 +
61 + @Test
62 + fun createdAtFlatChronoDescNoAzSections() {
63 + val contacts = listOf(
64 + contact(1, lastName = "A", createdAt = 10),
65 + contact(2, lastName = "B", createdAt = 30),
66 + contact(3, lastName = "C", createdAt = 20),
67 + )
68 + val grouped = AlphabetIndex.group(contacts, ContactSort.CREATED_AT)
69 + assertEquals(1, grouped.size)
70 + assertEquals(listOf(2L, 3L, 1L), grouped.values.single().map { it.id })
71 + }
72 +}
A android/app/src/test/java/fr/ebii/card2vcf/crm/ContactMergeTest.kt
+137 -0
@@ -0,0 +1,137 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +import kotlin.test.assertNull
7 +
8 +class ContactMergeTest {
9 + private fun contact(
10 + id: Long,
11 + fullName: String? = null,
12 + firstName: String? = null,
13 + lastName: String? = null,
14 + company: String? = null,
15 + jobTitle: String? = null,
16 + website: String? = null,
17 + address: String? = null,
18 + notes: String? = null,
19 + phones: List<String> = emptyList(),
20 + emails: List<String> = emptyList(),
21 + ) = CrmContactEntity(
22 + id = id,
23 + fullName = fullName,
24 + firstName = firstName,
25 + lastName = lastName,
26 + company = company,
27 + jobTitle = jobTitle,
28 + website = website,
29 + address = address,
30 + notes = notes,
31 + phones = phones,
32 + emails = emails,
33 + phonesText = phones.joinToString(" "),
34 + emailsText = emails.joinToString(" "),
35 + createdAt = id,
36 + updatedAt = id,
37 + )
38 +
39 + private fun choices(
40 + keepId: Long,
41 + notesMode: NotesMode = NotesMode.KEEP_PRIMARY,
42 + fullNameFromId: Long = keepId,
43 + firstNameFromId: Long = keepId,
44 + lastNameFromId: Long = keepId,
45 + companyFromId: Long = keepId,
46 + jobTitleFromId: Long = keepId,
47 + websiteFromId: Long = keepId,
48 + addressFromId: Long = keepId,
49 + ) = MergeFieldChoices(
50 + fullNameFromId = fullNameFromId,
51 + firstNameFromId = firstNameFromId,
52 + lastNameFromId = lastNameFromId,
53 + companyFromId = companyFromId,
54 + jobTitleFromId = jobTitleFromId,
55 + websiteFromId = websiteFromId,
56 + addressFromId = addressFromId,
57 + notesMode = notesMode,
58 + cardImageFromId = null,
59 + profileImageFromId = null,
60 + keepId = keepId,
61 + )
62 +
63 + @Test
64 + fun picksScalarsAndUnionsLists() {
65 + val a = contact(
66 + 1,
67 + fullName = "A",
68 + firstName = "Ada",
69 + company = "CoA",
70 + notes = "note A",
71 + phones = listOf("111"),
72 + emails = listOf("a@ex.com"),
73 + )
74 + val b = contact(
75 + 2,
76 + fullName = "B",
77 + lastName = "Lovelace",
78 + jobTitle = "Eng",
79 + website = "https://b",
80 + address = "Paris",
81 + notes = "note B",
82 + phones = listOf("111", "222"),
83 + emails = listOf("b@ex.com"),
84 + )
85 + val merged = ContactMerge.merge(
86 + mapOf(1L to a, 2L to b),
87 + choices(
88 + keepId = 1,
89 + fullNameFromId = 2,
90 + lastNameFromId = 2,
91 + jobTitleFromId = 2,
92 + websiteFromId = 2,
93 + addressFromId = 2,
94 + ),
95 + now = 99L,
96 + )
97 + assertEquals(1L, merged.id)
98 + assertEquals("B", merged.fullName)
99 + assertEquals("Ada", merged.firstName)
100 + assertEquals("Lovelace", merged.lastName)
101 + assertEquals("CoA", merged.company)
102 + assertEquals("Eng", merged.jobTitle)
103 + assertEquals("https://b", merged.website)
104 + assertEquals("Paris", merged.address)
105 + assertEquals("note A", merged.notes)
106 + assertEquals(listOf("111", "222"), merged.phones)
107 + assertEquals(listOf("a@ex.com", "b@ex.com"), merged.emails)
108 + assertEquals("111 222", merged.phonesText)
109 + assertEquals("a@ex.com b@ex.com", merged.emailsText)
110 + assertEquals(99L, merged.updatedAt)
111 + assertEquals(1L, merged.createdAt)
112 + }
113 +
114 + @Test
115 + fun concatNotes() {
116 + val a = contact(1, notes = "alpha")
117 + val b = contact(2, notes = "beta")
118 + val merged = ContactMerge.merge(
119 + mapOf(1L to a, 2L to b),
120 + choices(keepId = 1, notesMode = NotesMode.CONCAT),
121 + now = 1L,
122 + )
123 + assertEquals("alpha\n---\nbeta", merged.notes)
124 + }
125 +
126 + @Test
127 + fun concatNotesBlankBecomesNull() {
128 + val a = contact(1, notes = " ")
129 + val b = contact(2, notes = null)
130 + val merged = ContactMerge.merge(
131 + mapOf(1L to a, 2L to b),
132 + choices(keepId = 1, notesMode = NotesMode.CONCAT),
133 + now = 1L,
134 + )
135 + assertNull(merged.notes)
136 + }
137 +}
A android/app/src/test/java/fr/ebii/card2vcf/crm/ContactNormalizerTest.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +
6 +class ContactNormalizerTest {
7 + @Test
8 + fun emailTrimLowercase() {
9 + assertEquals("a@b.c", ContactNormalizer.email(" A@B.C "))
10 + }
11 +
12 + @Test
13 + fun phoneDigitsOnly() {
14 + assertEquals("3361234", ContactNormalizer.phone("+33 6 12-34"))
15 + }
16 +}
A android/app/src/test/java/fr/ebii/card2vcf/crm/DuplicateDetectorTest.kt
+48 -0
@@ -0,0 +1,48 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.data.DuplicateExclusionEntity
5 +import kotlin.test.Test
6 +import kotlin.test.assertEquals
7 +import kotlin.test.assertTrue
8 +
9 +class DuplicateDetectorTest {
10 + private fun contact(
11 + id: Long,
12 + emails: List<String> = emptyList(),
13 + phones: List<String> = emptyList(),
14 + ) = CrmContactEntity(id = id, emails = emails, phones = phones)
15 +
16 + @Test
17 + fun sameEmailSameCluster() {
18 + val a = contact(1, emails = listOf("Ada@Example.COM"))
19 + val b = contact(2, emails = listOf(" ada@example.com "))
20 + val c = contact(3, emails = listOf("other@ex.com"))
21 + val clusters = DuplicateDetector.clusters(listOf(a, b, c), emptyList())
22 + assertEquals(setOf(1L, 2L), clusters[1])
23 + assertEquals(setOf(1L, 2L), clusters[2])
24 + assertEquals(setOf(3L), clusters[3])
25 + assertEquals(1, DuplicateDetector.duplicateCount(1, clusters))
26 + assertEquals(0, DuplicateDetector.duplicateCount(3, clusters))
27 + }
28 +
29 + @Test
30 + fun exclusionSeparatesCluster() {
31 + val a = contact(1, emails = listOf("same@ex.com"))
32 + val b = contact(2, emails = listOf("same@ex.com"))
33 + val exclusions = listOf(DuplicateExclusionEntity(idA = 1, idB = 2))
34 + val clusters = DuplicateDetector.clusters(listOf(a, b), exclusions)
35 + assertEquals(setOf(1L), clusters[1])
36 + assertEquals(setOf(2L), clusters[2])
37 + assertEquals(0, DuplicateDetector.duplicateCount(1, clusters))
38 + }
39 +
40 + @Test
41 + fun normalizedPhoneMatch() {
42 + val a = contact(10, phones = listOf("+33 6 12-34"))
43 + val b = contact(20, phones = listOf("3361234"))
44 + val clusters = DuplicateDetector.clusters(listOf(a, b), emptyList())
45 + assertEquals(setOf(10L, 20L), clusters[10])
46 + assertTrue(DuplicateDetector.duplicateCount(10, clusters) == 1)
47 + }
48 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/AgendaSchemaDaoTest.kt
+123 -0
@@ -0,0 +1,123 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import kotlinx.coroutines.runBlocking
7 +import org.junit.After
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Assert.assertNotNull
10 +import org.junit.Assert.assertNull
11 +import org.junit.Assert.assertTrue
12 +import org.junit.Before
13 +import org.junit.Test
14 +import org.junit.runner.RunWith
15 +import org.robolectric.RobolectricTestRunner
16 +import org.robolectric.annotation.Config
17 +
18 +@RunWith(RobolectricTestRunner::class)
19 +@Config(sdk = [31])
20 +class AgendaSchemaDaoTest {
21 + private lateinit var db: CrmDatabase
22 + private lateinit var rdvDao: RdvDao
23 + private lateinit var reservationDao: ReservationDao
24 + private lateinit var indisponibiliteDao: IndisponibiliteDao
25 +
26 + @Before
27 + fun setUp() {
28 + val ctx = ApplicationProvider.getApplicationContext<Context>()
29 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
30 + .allowMainThreadQueries()
31 + .build()
32 + rdvDao = db.rdvDao()
33 + reservationDao = db.reservationDao()
34 + indisponibiliteDao = db.indisponibiliteDao()
35 + }
36 +
37 + @After
38 + fun tearDown() = db.close()
39 +
40 + @Test
41 + fun rdvUpsertGetListDirtyAndDelete() = runBlocking {
42 + val localId = rdvDao.upsert(
43 + RdvEntity(
44 + serverId = "rdv-1",
45 + titre = "Point client",
46 + lieu = "Bureau",
47 + debutMs = 1_000L,
48 + finMs = 2_000L,
49 + updatedAt = 1_000L,
50 + dirtyLocal = true,
51 + ),
52 + )
53 +
54 + val found = rdvDao.getByServerId("rdv-1")
55 + assertNotNull(found)
56 + assertEquals(localId, found!!.localId)
57 + assertEquals("Point client", found.titre)
58 + assertTrue(found.dirtyLocal)
59 +
60 + assertEquals(1, rdvDao.listDirty().size)
61 +
62 + rdvDao.upsert(found.copy(titre = "Point client reporté", updatedAt = 2_000L, dirtyLocal = false))
63 + assertEquals("Point client reporté", rdvDao.getByServerId("rdv-1")!!.titre)
64 + assertEquals(0, rdvDao.listDirty().size)
65 +
66 + rdvDao.deleteByServerId("rdv-1")
67 + assertNull(rdvDao.getByServerId("rdv-1"))
68 + }
69 +
70 + @Test
71 + fun reservationUpsertListByCibleAndConflictPending() = runBlocking {
72 + val localId = reservationDao.upsert(
73 + ReservationEntity(
74 + serverId = "resa-1",
75 + cibleType = "salle",
76 + cibleId = "salle-a",
77 + debutMs = 1_000L,
78 + finMs = 2_000L,
79 + motif = "Réunion",
80 + updatedAt = 1_000L,
81 + dirtyLocal = true,
82 + ),
83 + )
84 +
85 + val found = reservationDao.getByServerId("resa-1")
86 + assertNotNull(found)
87 + assertEquals(localId, found!!.localId)
88 + assertEquals("active", found.statut)
89 + assertEquals(1, reservationDao.listByCible("salle", "salle-a").size)
90 + assertEquals(1, reservationDao.listDirty().size)
91 +
92 + reservationDao.upsert(found.copy(conflictPending = true, dirtyLocal = false))
93 + val updated = reservationDao.getByServerId("resa-1")
94 + assertTrue(updated!!.conflictPending)
95 + assertEquals(0, reservationDao.listDirty().size)
96 +
97 + reservationDao.deleteByServerId("resa-1")
98 + assertNull(reservationDao.getByServerId("resa-1"))
99 + }
100 +
101 + @Test
102 + fun indisponibiliteUpsertListByCibleAndDelete() = runBlocking {
103 + indisponibiliteDao.upsert(
104 + IndisponibiliteEntity(
105 + serverId = "indispo-1",
106 + cibleType = "vehicule",
107 + cibleId = "vehicule-a",
108 + debutMs = 1_000L,
109 + finMs = 2_000L,
110 + nature = "maintenance",
111 + updatedAt = 1_000L,
112 + ),
113 + )
114 +
115 + val found = indisponibiliteDao.getByServerId("indispo-1")
116 + assertNotNull(found)
117 + assertEquals("maintenance", found!!.nature)
118 + assertEquals(1, indisponibiliteDao.listByCible("vehicule", "vehicule-a").size)
119 +
120 + indisponibiliteDao.deleteByServerId("indispo-1")
121 + assertNull(indisponibiliteDao.getByServerId("indispo-1"))
122 + }
123 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt
+169 -0
@@ -0,0 +1,169 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.contact.ContactCard
7 +import fr.ebii.card2vcf.crm.MergeFieldChoices
8 +import fr.ebii.card2vcf.crm.NotesMode
9 +import fr.ebii.card2vcf.sync.CreateContactRequest
10 +import fr.ebii.card2vcf.sync.syncJson
11 +import kotlinx.coroutines.runBlocking
12 +import kotlinx.serialization.decodeFromString
13 +import org.junit.After
14 +import org.junit.Assert.assertEquals
15 +import org.junit.Assert.assertNull
16 +import org.junit.Assert.assertTrue
17 +import org.junit.Before
18 +import org.junit.Test
19 +import org.junit.runner.RunWith
20 +import org.robolectric.RobolectricTestRunner
21 +import org.robolectric.annotation.Config
22 +
23 +@RunWith(RobolectricTestRunner::class)
24 +@Config(sdk = [31])
25 +class ContactRepositoryTest {
26 + private lateinit var db: CrmDatabase
27 + private lateinit var dao: CrmContactDao
28 + private lateinit var repo: ContactRepository
29 +
30 + @Before
31 + fun setUp() {
32 + val ctx = ApplicationProvider.getApplicationContext<Context>()
33 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
34 + .allowMainThreadQueries()
35 + .build()
36 + dao = db.crmContactDao()
37 + repo = ContactRepository(dao, ContactImageStore(ctx))
38 + }
39 +
40 + @After
41 + fun tearDown() = db.close()
42 +
43 + @Test
44 + fun insertAndSearchNotes() = runBlocking {
45 + val id = repo.insertFromCard(
46 + ContactCard(fullName = "Ada", note = "salon Lyon 2026"),
47 + cardBitmap = null,
48 + profileBitmap = null,
49 + now = 1L,
50 + )
51 + val hits = repo.search("Lyon")
52 + assertEquals(1, hits.size)
53 + assertEquals(id, hits[0].id)
54 + }
55 +
56 + @Test
57 + fun importSkipsDuplicateEmail() = runBlocking {
58 + repo.insertFromCard(
59 + ContactCard(fullName = "Ada", emails = listOf("Ada@Example.COM")),
60 + null,
61 + null,
62 + )
63 + val result = repo.importCards(
64 + listOf(
65 + ContactCard(fullName = "Ada 2", emails = listOf(" ada@example.com ")),
66 + ContactCard(fullName = "Bob", emails = listOf("bob@ex.com")),
67 + ),
68 + )
69 + assertEquals(1, result.imported)
70 + assertEquals(1, result.skippedDuplicates)
71 + assertEquals(0, result.errors)
72 + assertEquals(2, dao.getAll().size)
73 + }
74 +
75 + @Test
76 + fun deleteRemovesRow() = runBlocking {
77 + val id = repo.insertFromCard(
78 + ContactCard(fullName = "Temp"),
79 + null,
80 + null,
81 + )
82 + repo.delete(id)
83 + assertNull(dao.getById(id))
84 + assertTrue(dao.getAll().isEmpty())
85 + }
86 +
87 + @Test
88 + fun markDifferentStoresOrderedExclusion() = runBlocking {
89 + repo.markDifferent(5, 2)
90 + val ex = dao.getExclusions().single()
91 + assertEquals(2L, ex.idA)
92 + assertEquals(5L, ex.idB)
93 + }
94 +
95 + @Test
96 + fun enqueueCreateSyncOpWhenSyncEnabled() = runBlocking {
97 + val ctx = ApplicationProvider.getApplicationContext<Context>()
98 + val syncRepo = ContactRepository(
99 + dao = dao,
100 + images = ContactImageStore(ctx),
101 + syncOpDao = db.syncOpDao(),
102 + isSyncEnabled = { true },
103 + )
104 + val id = syncRepo.insertFromCard(
105 + ContactCard(fullName = "Sync Me", emails = listOf("sync@ex.com")),
106 + null,
107 + null,
108 + )
109 + val ops = db.syncOpDao().listAll()
110 + assertEquals(1, ops.size)
111 + assertEquals("contact", ops[0].entityType)
112 + assertEquals("create", ops[0].op)
113 + assertEquals(id, ops[0].localId)
114 + val payload = syncJson.decodeFromString<CreateContactRequest>(ops[0].payloadJson)
115 + assertEquals("sync@ex.com", payload.emails.single().valeur)
116 + }
117 +
118 + @Test
119 + fun deleteUnsyncedContactRemovesPendingCreateOp() = runBlocking {
120 + val ctx = ApplicationProvider.getApplicationContext<Context>()
121 + val syncRepo = ContactRepository(
122 + dao = dao,
123 + images = ContactImageStore(ctx),
124 + syncOpDao = db.syncOpDao(),
125 + isSyncEnabled = { true },
126 + )
127 + val id = syncRepo.insertFromCard(ContactCard(fullName = "Temp"), null, null)
128 + assertEquals(1, db.syncOpDao().listAll().size)
129 + syncRepo.delete(id)
130 + assertTrue(db.syncOpDao().listAll().isEmpty())
131 + }
132 +
133 + @Test
134 + fun mergeContactsKeepsOneAndDeletesOthers() = runBlocking {
135 + val idA = repo.insertFromCard(
136 + ContactCard(fullName = "A", emails = listOf("a@ex.com"), phones = listOf("111")),
137 + null,
138 + null,
139 + )
140 + val idB = repo.insertFromCard(
141 + ContactCard(fullName = "B", emails = listOf("b@ex.com"), phones = listOf("222")),
142 + null,
143 + null,
144 + )
145 + val a = dao.getById(idA)!!
146 + val b = dao.getById(idB)!!
147 + repo.mergeContacts(
148 + listOf(a, b),
149 + MergeFieldChoices(
150 + fullNameFromId = idB,
151 + firstNameFromId = idA,
152 + lastNameFromId = idA,
153 + companyFromId = idA,
154 + jobTitleFromId = idA,
155 + websiteFromId = idA,
156 + addressFromId = idA,
157 + notesMode = NotesMode.KEEP_PRIMARY,
158 + cardImageFromId = null,
159 + profileImageFromId = null,
160 + keepId = idA,
161 + ),
162 + )
163 + assertNull(dao.getById(idB))
164 + val kept = dao.getById(idA)!!
165 + assertEquals("B", kept.fullName)
166 + assertEquals(listOf("111", "222"), kept.phones)
167 + assertEquals(listOf("a@ex.com", "b@ex.com"), kept.emails)
168 + }
169 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt
+25 -0
@@ -0,0 +1,25 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +import org.junit.runner.RunWith
6 +import org.robolectric.RobolectricTestRunner
7 +import org.robolectric.annotation.Config
8 +
9 +@RunWith(RobolectricTestRunner::class)
10 +@Config(sdk = [31])
11 +class ConvertersTest {
12 + private val c = Converters()
13 +
14 + @Test
15 + fun roundTripStringList() {
16 + val list = listOf("a@b.c", "x@y.z")
17 + assertEquals(list, c.toStringList(c.fromStringList(list)))
18 + }
19 +
20 + @Test
21 + fun nullOrEmpty() {
22 + assertEquals(emptyList<String>(), c.toStringList(null))
23 + assertEquals("[]", c.fromStringList(emptyList()))
24 + }
25 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/CrmContactDaoTest.kt
+48 -0
@@ -0,0 +1,48 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import kotlinx.coroutines.runBlocking
7 +import org.junit.After
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Before
10 +import org.junit.Test
11 +import org.junit.runner.RunWith
12 +import org.robolectric.RobolectricTestRunner
13 +import org.robolectric.annotation.Config
14 +
15 +@RunWith(RobolectricTestRunner::class)
16 +@Config(sdk = [31])
17 +class CrmContactDaoTest {
18 + private lateinit var db: CrmDatabase
19 + private lateinit var dao: CrmContactDao
20 +
21 + @Before
22 + fun setUp() {
23 + val ctx = ApplicationProvider.getApplicationContext<Context>()
24 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
25 + .allowMainThreadQueries()
26 + .build()
27 + dao = db.crmContactDao()
28 + }
29 +
30 + @After
31 + fun tearDown() = db.close()
32 +
33 + @Test
34 + fun insertAndSearchNotes() = runBlocking {
35 + val id = dao.insert(
36 + CrmContactEntity(
37 + fullName = "Ada",
38 + notes = "salon Lyon 2026",
39 + phonesText = "",
40 + emailsText = "",
41 + createdAt = 1L,
42 + updatedAt = 1L,
43 + ),
44 + )
45 + val hits = dao.searchIds("Lyon*")
46 + assertEquals(listOf(id), hits)
47 + }
48 +}
A android/app/src/test/java/fr/ebii/card2vcf/kanban/KanbanFilterTest.kt
+48 -0
@@ -0,0 +1,48 @@
1 +package fr.ebii.card2vcf.kanban
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +
7 +class KanbanFilterTest {
8 + private fun tache(localId: Long, assigneA: String? = null) = TacheEntity(
9 + localId = localId,
10 + titre = "T$localId",
11 + assigneA = assigneA,
12 + )
13 +
14 + @Test
15 + fun mine_keepsOnlyTasksAssignedToUser() {
16 + val tasks = listOf(
17 + tache(1, "alice"),
18 + tache(2, "bob"),
19 + tache(3, null),
20 + )
21 +
22 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.MINE, "alice")
23 +
24 + assertEquals(listOf(tasks[0]), result)
25 + }
26 +
27 + @Test
28 + fun all_keepsEveryTaskIncludingUnassigned() {
29 + val tasks = listOf(
30 + tache(1, "alice"),
31 + tache(2, "bob"),
32 + tache(3, null),
33 + )
34 +
35 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.ALL, "alice")
36 +
37 + assertEquals(tasks, result)
38 + }
39 +
40 + @Test
41 + fun mine_excludesUnassignedTasks() {
42 + val tasks = listOf(tache(1, null))
43 +
44 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.MINE, "alice")
45 +
46 + assertEquals(emptyList(), result)
47 + }
48 +}
A android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrOrientationTest.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertTrue
5 +
6 +class OcrOrientationTest {
7 + @Test fun scorePrefereEmailEtTelephone() {
8 + val weak = OcrResult(rawText = "abc", phones = emptyList(), emails = emptyList())
9 + val strong = OcrResult(
10 + rawText = "Jean Paul GENOUX\njpgenoux@dimosoftware.com\n+33674640544",
11 + phones = listOf("+33674640544"),
12 + emails = listOf("jpgenoux@dimosoftware.com"),
13 + urls = listOf("https://www.dimosoftware.fr"),
14 + )
15 + assertTrue(OcrOrientation.score(strong) > OcrOrientation.score(weak))
16 + }
17 +}
A android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrPostProcessingTest.kt
+50 -0
@@ -0,0 +1,50 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class OcrPostProcessingTest {
8 + @Test fun extraitEmailEtTelephone() {
9 + val text = """
10 + Jean Dupont
11 + Directeur
12 + 06 12 34 56 78
13 + jean.dupont@acme.fr
14 + https://acme.fr
15 + """.trimIndent()
16 + val r = OcrPostProcessor.process(text)
17 + assertTrue(r.emails.contains("jean.dupont@acme.fr"))
18 + assertTrue(r.phones.any { it.contains("0612345678") })
19 + assertTrue(r.urls.contains("https://acme.fr"))
20 + }
21 +
22 + @Test fun multiEmails() {
23 + val r = OcrPostProcessor.process("a@b.fr et c@d.fr")
24 + assertEquals(2, r.emails.size)
25 + }
26 +
27 + @Test fun lignesNettoyees() {
28 + val r = OcrPostProcessor.process(" ligne1 \n\n ligne2 ")
29 + assertEquals(listOf("ligne1", "ligne2"), r.lines)
30 + }
31 +
32 + @Test fun extraitCarteDimoCommeSurAppareil() {
33 + val text = """
34 + dimo
35 + SOFTWARE
36 + Jean Pau! GENOUX
37 + Directeur Général/CEO
38 + jpgenoux@dimosoftware.com
39 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
40 + Siège Social
41 + 561, alée des Noisetiers - 69 760 Limonest - France
42 + www.dimosoftware.fr
43 + """.trimIndent()
44 + val r = OcrPostProcessor.process(text)
45 + assertEquals(listOf("jpgenoux@dimosoftware.com"), r.emails)
46 + assertTrue(r.phones.any { it == "+33674640544" || it.endsWith("674640544") })
47 + assertTrue(r.phones.any { it.contains("472860192") || it.endsWith("472860192") })
48 + assertTrue(r.urls.any { it.contains("dimosoftware.fr") })
49 + }
50 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/CapturePipelineRobustnessTest.kt
+74 -0
@@ -0,0 +1,74 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.BitmapFactory
4 +import fr.ebii.card2vcf.ocr.OcrPostProcessor
5 +import org.junit.runner.RunWith
6 +import org.robolectric.RobolectricTestRunner
7 +import org.robolectric.annotation.Config
8 +import kotlin.test.Test
9 +import kotlin.test.assertEquals
10 +import kotlin.test.assertTrue
11 +
12 +/**
13 + * Pipeline générique : EXIF → (scan simulé) → post-OCR,
14 + * calé sur les difficultés utilisateur observées (orientation, bruit OCR).
15 + */
16 +@RunWith(RobolectricTestRunner::class)
17 +@Config(sdk = [31])
18 +class CapturePipelineRobustnessTest {
19 +
20 + @Test fun photoDimoExifPuisExtractionContact() {
21 + val rawJpeg = javaClass.classLoader!!
22 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
23 + .readBytes()
24 +
25 + val oriented = ImageOrientation.fromJpegBytes(rawJpeg)!!
26 + assertTrue(oriented.fromExif)
27 + assertEquals(90, oriented.appliedRotationDegrees)
28 +
29 + // Texte OCR représentatif de ce que Tesseract produit sur cette carte (mesuré sur appareil).
30 + val ocrBruite = """
31 + dimo
32 + SOFTWARE &—
33 + Jean Pau! GENOUX
34 + Directeur Général/CEO
35 + jpgenoux@dimosoftware.com
36 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
37 + Siège Social
38 + 561, alée des Noisetiers - 69 760 Limonest - France
39 + www.dimosoftware.fr
40 + """.trimIndent()
41 +
42 + val fields = OcrPostProcessor.process(ocrBruite)
43 + assertEquals(listOf("jpgenoux@dimosoftware.com"), fields.emails)
44 + assertTrue(fields.phones.any { it.endsWith("674640544") })
45 + assertTrue(fields.phones.any { it.contains("472860192") })
46 + assertTrue(fields.urls.any { it.contains("dimosoftware.fr") })
47 + assertTrue(oriented.bitmap.height > oriented.bitmap.width)
48 + }
49 +
50 + @Test fun photoSansExifMaisRotationCameraResteLisible() {
51 + // Image déjà orientée (après transpose) : EXIF normal, CameraX dit 0.
52 + val orientedJpeg = javaClass.classLoader!!
53 + .getResourceAsStream("cards/dimo_genoux_oriented.jpg")!!
54 + .readBytes()
55 + val bmp = BitmapFactory.decodeByteArray(orientedJpeg, 0, orientedJpeg.size)!!
56 + val result = ImageOrientation.fromJpegBytes(orientedJpeg, cameraRotationDegrees = 0)!!
57 + assertEquals(bmp.width, result.bitmap.width)
58 + assertEquals(bmp.height, result.bitmap.height)
59 + }
60 +
61 + @Test fun toutesOrientationsExifPrincipalesSontGerees() {
62 + val cases = listOf(
63 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL, 0, false),
64 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90, 90, true),
65 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180, 180, true),
66 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270, 270, true),
67 + )
68 + for (c in cases) {
69 + assertEquals(c.degrees, ImageOrientation.exifRotationDegrees(c.orientation))
70 + }
71 + }
72 +
73 + private data class ExifCase(val orientation: Int, val degrees: Int, val useful: Boolean)
74 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/DimoCardScanTest.kt
+47 -0
@@ -0,0 +1,47 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.PointF
4 +import org.junit.runner.RunWith
5 +import org.robolectric.RobolectricTestRunner
6 +import org.robolectric.annotation.Config
7 +import kotlin.test.Test
8 +import kotlin.test.assertNotNull
9 +import kotlin.test.assertTrue
10 +
11 +@RunWith(RobolectricTestRunner::class)
12 +@Config(sdk = [31])
13 +class DimoCardScanTest {
14 +
15 + @Test fun scanApresCorrectionExif() {
16 + if (!OpenCvScanEngine.ensureOpenCv()) return
17 + val bytes = javaClass.classLoader!!
18 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
19 + .readBytes()
20 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
21 + assertTrue(oriented.fromExif)
22 + val result = OpenCvScanEngine().scan(oriented.bitmap)
23 + assertNotNull(result.bitmap)
24 + assertTrue(result.bitmap.width > 100)
25 + assertTrue(result.bitmap.height > 100)
26 + println("SCAN dimo ${result.bitmap.width}x${result.bitmap.height} conf=${result.confidence}")
27 + }
28 +
29 + @Test fun scanAvecCoinsManuelsResteStable() {
30 + if (!OpenCvScanEngine.ensureOpenCv()) return
31 + val bytes = javaClass.classLoader!!
32 + .getResourceAsStream("cards/dimo_genoux_oriented.jpg")!!
33 + .readBytes()
34 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
35 + val w = oriented.bitmap.width.toFloat()
36 + val h = oriented.bitmap.height.toFloat()
37 + val margin = 0.05f
38 + val corners = ScanCorners(
39 + topLeft = PointF(w * margin, h * margin),
40 + topRight = PointF(w * (1 - margin), h * margin),
41 + bottomRight = PointF(w * (1 - margin), h * (1 - margin)),
42 + bottomLeft = PointF(w * margin, h * (1 - margin)),
43 + )
44 + val result = OpenCvScanEngine().scan(oriented.bitmap, corners)
45 + assertTrue(result.confidence >= 0.9f)
46 + }
47 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/ImageOrientationTest.kt
+130 -0
@@ -0,0 +1,130 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Canvas
5 +import android.graphics.Color
6 +import android.graphics.Paint
7 +import androidx.exifinterface.media.ExifInterface
8 +import org.junit.runner.RunWith
9 +import org.robolectric.RobolectricTestRunner
10 +import org.robolectric.annotation.Config
11 +import java.io.ByteArrayOutputStream
12 +import java.io.File
13 +import kotlin.test.Test
14 +import kotlin.test.assertEquals
15 +import kotlin.test.assertFalse
16 +import kotlin.test.assertNotNull
17 +import kotlin.test.assertTrue
18 +
19 +/**
20 + * Tests génériques d'orientation : EXIF, fallback CameraX, anti double-rotation,
21 + * et fixture réelle (carte dimo photographiée en conditions utilisateur).
22 + */
23 +@RunWith(RobolectricTestRunner::class)
24 +@Config(sdk = [31])
25 +class ImageOrientationTest {
26 +
27 + @Test fun exif6FaitTourner90EtInverseDimensions() {
28 + val src = solidBitmap(200, 100, Color.RED)
29 + val jpeg = encodeJpeg(src)
30 + val file = writeJpegWithExif(jpeg, ExifInterface.ORIENTATION_ROTATE_90)
31 + val oriented = ImageOrientation.fromFile(file)!!
32 + assertTrue(oriented.fromExif)
33 + assertEquals(90, oriented.appliedRotationDegrees)
34 + assertEquals(100, oriented.bitmap.width)
35 + assertEquals(200, oriented.bitmap.height)
36 + }
37 +
38 + @Test fun exif3FaitTourner180SansChangerDimensions() {
39 + val src = solidBitmap(160, 80, Color.BLUE)
40 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_180)
41 + val oriented = ImageOrientation.fromFile(file)!!
42 + assertEquals(180, oriented.appliedRotationDegrees)
43 + assertEquals(160, oriented.bitmap.width)
44 + assertEquals(80, oriented.bitmap.height)
45 + }
46 +
47 + @Test fun exif8FaitTourner270() {
48 + val src = solidBitmap(120, 60, Color.GREEN)
49 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_270)
50 + val oriented = ImageOrientation.fromFile(file)!!
51 + assertEquals(270, oriented.appliedRotationDegrees)
52 + assertEquals(60, oriented.bitmap.width)
53 + assertEquals(120, oriented.bitmap.height)
54 + }
55 +
56 + @Test fun sansExifUtiliseRotationCameraX() {
57 + val src = solidBitmap(200, 100, Color.YELLOW)
58 + val jpeg = encodeJpeg(src) // EXIF normal / absent
59 + val oriented = ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees = 90)!!
60 + assertFalse(oriented.fromExif)
61 + assertEquals(90, oriented.appliedRotationDegrees)
62 + assertEquals(100, oriented.bitmap.width)
63 + assertEquals(200, oriented.bitmap.height)
64 + }
65 +
66 + @Test fun exifPrioritaireSurCameraXPasDeDoubleRotation() {
67 + val src = solidBitmap(200, 100, Color.MAGENTA)
68 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_90)
69 + // CameraX dirait aussi 90° : si on cumule → 180° et dimensions d'origine. On refuse ça.
70 + val oriented = ImageOrientation.fromFile(file, cameraRotationDegrees = 90)!!
71 + assertTrue(oriented.fromExif)
72 + assertEquals(90, oriented.appliedRotationDegrees)
73 + assertEquals(100, oriented.bitmap.width)
74 + assertEquals(200, oriented.bitmap.height)
75 + }
76 +
77 + @Test fun normalizeDegreesQuantifieAuxQuarts() {
78 + assertEquals(0, ImageOrientation.normalizeDegrees(10))
79 + assertEquals(90, ImageOrientation.normalizeDegrees(95))
80 + assertEquals(180, ImageOrientation.normalizeDegrees(190))
81 + assertEquals(270, ImageOrientation.normalizeDegrees(-90))
82 + }
83 +
84 + @Test fun carteDimoReelleOrientationExif6() {
85 + val bytes = javaClass.classLoader!!
86 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
87 + .readBytes()
88 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
89 + assertNotNull(oriented.bitmap)
90 + // Photo OnePlus : EXIF orientation 6 → 90° → portrait 3008x4000
91 + assertTrue(oriented.fromExif, "la carte dimo doit être corrigée via EXIF")
92 + assertEquals(90, oriented.appliedRotationDegrees)
93 + assertEquals(3008, oriented.bitmap.width)
94 + assertEquals(4000, oriented.bitmap.height)
95 + assertTrue(oriented.bitmap.height > oriented.bitmap.width, "attendu portrait après EXIF")
96 + }
97 +
98 + @Test fun carteDimoNeDoitPasEtreDoubleTourneeSiCameraDitAussi90() {
99 + val bytes = javaClass.classLoader!!
100 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
101 + .readBytes()
102 + val oriented = ImageOrientation.fromJpegBytes(bytes, cameraRotationDegrees = 90)!!
103 + assertEquals(3008, oriented.bitmap.width)
104 + assertEquals(4000, oriented.bitmap.height)
105 + }
106 +
107 + private fun solidBitmap(w: Int, h: Int, color: Int): Bitmap {
108 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
109 + Canvas(bmp).drawColor(color)
110 + // marqueur coin pour debug visuel
111 + Canvas(bmp).drawRect(0f, 0f, 8f, 8f, Paint().apply { this.color = Color.BLACK })
112 + return bmp
113 + }
114 +
115 + private fun encodeJpeg(bitmap: Bitmap): ByteArray {
116 + val out = ByteArrayOutputStream()
117 + bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
118 + return out.toByteArray()
119 + }
120 +
121 + private fun writeJpegWithExif(jpeg: ByteArray, orientation: Int): File {
122 + val file = File.createTempFile("orient", ".jpg")
123 + file.writeBytes(jpeg)
124 + ExifInterface(file.absolutePath).apply {
125 + setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
126 + saveAttributes()
127 + }
128 + return file
129 + }
130 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/OpenCvScanEngineTest.kt
+66 -0
@@ -0,0 +1,66 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Canvas
5 +import android.graphics.Color
6 +import android.graphics.Paint
7 +import android.graphics.PointF
8 +import org.junit.runner.RunWith
9 +import org.robolectric.RobolectricTestRunner
10 +import org.robolectric.annotation.Config
11 +import kotlin.test.Test
12 +import kotlin.test.assertNotNull
13 +import kotlin.test.assertTrue
14 +
15 +@RunWith(RobolectricTestRunner::class)
16 +@Config(sdk = [31])
17 +class OpenCvScanEngineTest {
18 +
19 + @Test fun redresseDocumentRectangulaire() {
20 + if (!OpenCvScanEngine.ensureOpenCv()) return
21 + val bmp = cardBitmap(margin = 40)
22 + val engine = OpenCvScanEngine()
23 + val corners = ScanCorners(
24 + topLeft = PointF(40f, 40f),
25 + topRight = PointF(559f, 40f),
26 + bottomRight = PointF(559f, 339f),
27 + bottomLeft = PointF(40f, 339f),
28 + )
29 + val result = engine.scan(bmp, corners)
30 + assertNotNull(result.bitmap)
31 + assertTrue(result.bitmap.width > 0)
32 + }
33 +
34 + @Test fun autoDetectCarteBlancheSurFondNoir() {
35 + if (!OpenCvScanEngine.ensureOpenCv()) return
36 + val result = OpenCvScanEngine().scan(cardBitmap(margin = 40))
37 + assertNotNull(result.bitmap)
38 + assertTrue(result.bitmap.width > 100)
39 + assertTrue(result.confidence >= 0.4f)
40 + }
41 +
42 + @Test fun fallbackCadreCompletSiPasDeContour() {
43 + if (!OpenCvScanEngine.ensureOpenCv()) return
44 + // Image uniforme : aucun contour, le fallback cadre complet doit quand même produire une sortie.
45 + val bmp = Bitmap.createBitmap(200, 120, Bitmap.Config.ARGB_8888)
46 + Canvas(bmp).drawColor(Color.GRAY)
47 + val result = OpenCvScanEngine().scan(bmp)
48 + assertNotNull(result.bitmap)
49 + assertTrue(result.confidence <= 0.5f)
50 + }
51 +
52 + private fun cardBitmap(margin: Int): Bitmap {
53 + val bmp = Bitmap.createBitmap(600, 380, Bitmap.Config.ARGB_8888)
54 + val canvas = Canvas(bmp)
55 + canvas.drawColor(Color.BLACK)
56 + canvas.drawRect(
57 + margin.toFloat(), margin.toFloat(),
58 + (600 - margin).toFloat(), (380 - margin).toFloat(),
59 + Paint().apply { color = Color.WHITE },
60 + )
61 + val paint = Paint().apply { color = Color.BLACK; textSize = 28f }
62 + canvas.drawText("Jean Dupont", (margin + 20).toFloat(), (margin + 50).toFloat(), paint)
63 + canvas.drawText("0612345678", (margin + 20).toFloat(), (margin + 100).toFloat(), paint)
64 + return bmp
65 + }
66 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/AgendaSyncEngineTest.kt
+478 -0
@@ -0,0 +1,478 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.RdvEntity
8 +import fr.ebii.card2vcf.data.ReservationEntity
9 +import kotlinx.coroutines.runBlocking
10 +import org.junit.After
11 +import org.junit.Assert.assertEquals
12 +import org.junit.Assert.assertNull
13 +import org.junit.Assert.assertTrue
14 +import org.junit.Before
15 +import org.junit.Test
16 +import org.junit.runner.RunWith
17 +import org.robolectric.RobolectricTestRunner
18 +import org.robolectric.annotation.Config
19 +import java.time.Instant
20 +
21 +/** Task M5 : LWW RDV, conflit 409 réservation, abandon, `ressourcesQuery`, pont Agenda↔Room. */
22 +@RunWith(RobolectricTestRunner::class)
23 +@Config(sdk = [31])
24 +class AgendaSyncEngineTest {
25 + private lateinit var db: CrmDatabase
26 + private lateinit var api: FakeAilianceApi
27 + private lateinit var engine: SyncEngine
28 +
29 + @Before
30 + fun setUp() {
31 + val ctx = ApplicationProvider.getApplicationContext<Context>()
32 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
33 + .allowMainThreadQueries()
34 + .build()
35 + api = FakeAilianceApi()
36 + engine = SyncEngine(api, db)
37 + }
38 +
39 + @After
40 + fun tearDown() = db.close()
41 +
42 + private fun epoch(iso: String) = Instant.parse(iso).toEpochMilli()
43 +
44 + // ---- LWW RDV ----
45 +
46 + @Test
47 + fun syncNow_rdvPullNewerServerOverwritesCleanLocal() = runBlocking {
48 + db.rdvDao().upsert(
49 + RdvEntity(serverId = "rdv1", titre = "Ancien titre", updatedAt = epoch("2026-07-20T10:00:00Z")),
50 + )
51 + api.pullResult = AilianceApiClient.ApiResult.Ok(
52 + SyncPullResponse(
53 + serverTime = "2026-07-22T12:00:00Z",
54 + rdv = listOf(
55 + RendezVousDto(
56 + id = "rdv1",
57 + titre = "Nouveau titre serveur",
58 + debut = "2026-07-22T09:00:00Z",
59 + fin = "2026-07-22T10:00:00Z",
60 + creeLe = "2026-07-20T10:00:00Z",
61 + misAJourLe = "2026-07-22T10:00:00Z",
62 + ),
63 + ),
64 + ),
65 + )
66 +
67 + engine.syncNow()
68 +
69 + val updated = db.rdvDao().getByServerId("rdv1")
70 + assertEquals("Nouveau titre serveur", updated?.titre)
71 + }
72 +
73 + @Test
74 + fun syncNow_rdvPullDirtyLocalNewerKeepsLocal() = runBlocking {
75 + db.rdvDao().upsert(
76 + RdvEntity(
77 + serverId = "rdv2",
78 + titre = "Titre local en avance",
79 + updatedAt = epoch("2026-07-22T10:00:00Z"),
80 + dirtyLocal = true,
81 + ),
82 + )
83 + api.pullResult = AilianceApiClient.ApiResult.Ok(
84 + SyncPullResponse(
85 + serverTime = "2026-07-22T12:00:00Z",
86 + rdv = listOf(
87 + RendezVousDto(
88 + id = "rdv2",
89 + titre = "Titre serveur périmé",
90 + debut = "2026-07-19T09:00:00Z",
91 + fin = "2026-07-19T10:00:00Z",
92 + creeLe = "2026-07-18T10:00:00Z",
93 + misAJourLe = "2026-07-20T10:00:00Z",
94 + ),
95 + ),
96 + ),
97 + )
98 +
99 + engine.syncNow()
100 +
101 + val kept = db.rdvDao().getByServerId("rdv2")
102 + assertEquals("Titre local en avance", kept?.titre)
103 + assertTrue(kept?.dirtyLocal == true)
104 + }
105 +
106 + // ---- Conflit 409 réservation ----
107 +
108 + @Test
109 + fun syncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() = runBlocking {
110 + val localId = db.reservationDao().upsert(
111 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
112 + )
113 + db.syncOpDao().insert(
114 + SyncOpEntity(
115 + entityType = "reservation",
116 + op = "create",
117 + payloadJson = """{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}""",
118 + localId = localId,
119 + createdAt = 1L,
120 + ),
121 + )
122 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement avec une autre réservation")
123 +
124 + val result = engine.syncNow()
125 +
126 + assertTrue(result.conflicts.isNotEmpty())
127 + assertEquals(localId, result.conflicts.single().localReservationId)
128 + assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
129 + assertEquals(1, db.syncOpDao().listAll().size)
130 + assertEquals("salle", api.createReservationCalls.single().first)
131 + assertEquals("salle-1", api.createReservationCalls.single().second)
132 + }
133 +
134 + // ---- Abandon ----
135 +
136 + @Test
137 + fun abandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() = runBlocking {
138 + val localId = db.reservationDao().upsert(
139 + ReservationEntity(
140 + cibleType = "salle",
141 + cibleId = "salle-1",
142 + motif = "Réunion",
143 + conflictPending = true,
144 + calendarEventId = 42L,
145 + ),
146 + )
147 + db.syncOpDao().insert(
148 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
149 + )
150 + val bridge = FakeCalendarBridge()
151 +
152 + engine.abandonLocalReservation(localId, bridge)
153 +
154 + assertNull(db.reservationDao().getByLocalId(localId))
155 + assertTrue(db.syncOpDao().listAll().isEmpty())
156 + assertTrue(bridge.deletedEventIds.contains(42L))
157 + }
158 +
159 + // ---- ressourcesQuery ----
160 +
161 + @Test
162 + fun ressourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() {
163 + val coordinator = AgendaSyncCoordinator(db)
164 + val bindings = listOf(
165 + CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = 1L),
166 + CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = 2L),
167 + CalendarBinding(kind = "materiel", serverResourceId = "m1", displayName = "Matériel B", androidCalendarId = 3L),
168 + CalendarBinding(kind = "vehicule", serverResourceId = null, displayName = "Sans id serveur", androidCalendarId = 4L),
169 + )
170 +
171 + assertEquals("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings))
172 + }
173 +
174 + @Test
175 + fun ressourcesQuery_emptyBindingsReturnsNull() {
176 + val coordinator = AgendaSyncCoordinator(db)
177 +
178 + assertNull(coordinator.ressourcesQuery(emptyList()))
179 + }
180 +
181 + @Test
182 + fun syncNow_pullUsesRessourcesQueryDerivedFromBindings() = runBlocking {
183 + val bridge = FakeCalendarBridge()
184 + val rdvCalendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
185 + val salleCalendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
186 + val bindings = listOf(
187 + CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = rdvCalendarId),
188 + CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = salleCalendarId),
189 + )
190 +
191 + engine.syncNow(bindings, bridge)
192 +
193 + assertEquals("salle:s1", api.pullQueries.last())
194 + }
195 +
196 + @Test
197 + fun checkStatus_forwardsRessourcesQueryToApi() = runBlocking {
198 + engine.checkStatus("salle:s1")
199 +
200 + assertEquals("salle:s1", api.statusQueries.last())
201 + }
202 +
203 + @Test
204 + fun syncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() = runBlocking {
205 + engine.syncNow()
206 +
207 + assertNull(api.pullQueries.last())
208 + }
209 +
210 + // ---- Agenda -> Room / Room -> Agenda ----
211 +
212 + @Test
213 + fun syncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() = runBlocking {
214 + val bridge = FakeCalendarBridge()
215 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
216 + bridge.seedEvent(
217 + calendarId,
218 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, descriptionBody = "notes"),
219 + )
220 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
221 +
222 + engine.syncNow(bindings, bridge)
223 +
224 + val stored = db.rdvDao().listAll()
225 + assertEquals(1, stored.size)
226 + assertEquals("RDV client", stored.single().titre)
227 + assertEquals(1, api.createRdvCalls.size)
228 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" })
229 + }
230 +
231 + @Test
232 + fun syncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() = runBlocking {
233 + val bridge = FakeCalendarBridge()
234 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
235 + val localId = db.rdvDao().upsert(
236 + RdvEntity(serverId = "rdv-9", titre = "RDV serveur", debutMs = 5_000L, finMs = 6_000L, updatedAt = 1L),
237 + )
238 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
239 +
240 + engine.syncNow(bindings, bridge)
241 +
242 + val updated = db.rdvDao().getByLocalId(localId)
243 + assertTrue(updated?.calendarEventId != null)
244 + assertEquals(1, bridge.listEvents(calendarId).size)
245 + assertEquals("RDV serveur", bridge.listEvents(calendarId).single().title)
246 + }
247 +
248 + // ---- Fix HIGH : roomToAgenda supprime les events orphelins ----
249 +
250 + @Test
251 + fun syncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() = runBlocking {
252 + val bridge = FakeCalendarBridge()
253 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
254 + val eventId = bridge.seedEvent(
255 + calendarId,
256 + CalendarEventSnapshot(title = "RDV serveur", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-old"),
257 + )
258 + db.rdvDao().upsert(RdvEntity(serverId = "rdv-old", titre = "RDV serveur", calendarEventId = eventId, updatedAt = 1L))
259 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
260 + api.pullResult = AilianceApiClient.ApiResult.Ok(
261 + SyncPullResponse(
262 + serverTime = "2026-07-22T12:00:00Z",
263 + tombstones = listOf(TombstoneDto(entityType = "rdv", id = "rdv-old", supprimeLe = "2026-07-22T12:00:00Z")),
264 + ),
265 + )
266 +
267 + engine.syncNow(bindings, bridge)
268 +
269 + assertNull(db.rdvDao().getByServerId("rdv-old"))
270 + assertTrue(bridge.deletedEventIds.contains(eventId))
271 + assertTrue(bridge.listEvents(calendarId).isEmpty())
272 + }
273 +
274 + @Test
275 + fun syncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() = runBlocking {
276 + val bridge = FakeCalendarBridge()
277 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
278 + val eventId = bridge.seedEvent(
279 + calendarId,
280 + CalendarEventSnapshot(title = "Réunion", debutMs = 1_000L, finMs = 2_000L, serverId = "resa-1"),
281 + )
282 + db.reservationDao().upsert(
283 + ReservationEntity(
284 + serverId = "resa-1",
285 + cibleType = "salle",
286 + cibleId = "s1",
287 + motif = "Réunion",
288 + calendarEventId = eventId,
289 + updatedAt = 1L,
290 + statut = "active",
291 + ),
292 + )
293 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
294 + api.pullResult = AilianceApiClient.ApiResult.Ok(
295 + SyncPullResponse(
296 + serverTime = "2026-07-22T12:00:00Z",
297 + reservations = listOf(
298 + ReservationDto(
299 + id = "resa-1",
300 + cible = CibleRessourceDto(type = "salle", id = "s1"),
301 + debut = "2026-07-22T09:00:00Z",
302 + fin = "2026-07-22T10:00:00Z",
303 + statut = "annulee",
304 + creeLe = "2026-07-20T10:00:00Z",
305 + misAJourLe = "2026-07-22T11:00:00Z",
306 + ),
307 + ),
308 + ),
309 + )
310 +
311 + engine.syncNow(bindings, bridge)
312 +
313 + assertNull(db.reservationDao().getByServerId("resa-1"))
314 + assertTrue(bridge.deletedEventIds.contains(eventId))
315 + }
316 +
317 + @Test
318 + fun syncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() = runBlocking {
319 + val bridge = FakeCalendarBridge()
320 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
321 + val eventId = bridge.seedEvent(
322 + calendarId,
323 + CalendarEventSnapshot(title = "Nouveau RDV", debutMs = 1_000L, finMs = 2_000L),
324 + )
325 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
326 + api.createRdvResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible")
327 +
328 + engine.syncNow(bindings, bridge)
329 +
330 + val stored = db.rdvDao().listAll().single()
331 + assertNull(stored.serverId)
332 + assertTrue(bridge.deletedEventIds.isEmpty())
333 + assertTrue(bridge.listEvents(calendarId).any { it.eventId == eventId })
334 + }
335 +
336 + // ---- Fix MEDIUM : agendaToRoom détecte les suppressions côté Agenda ----
337 +
338 + @Test
339 + fun agendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() = runBlocking {
340 + val bridge = FakeCalendarBridge()
341 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
342 + db.rdvDao().upsert(RdvEntity(serverId = "rdv-5", titre = "RDV supprimé", calendarEventId = 99L, updatedAt = 1L))
343 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
344 + val coordinator = AgendaSyncCoordinator(db)
345 +
346 + coordinator.agendaToRoom(bindings, bridge)
347 +
348 + val op = db.syncOpDao().listAll().single { it.entityType == "rdv" }
349 + assertEquals("delete", op.op)
350 + assertEquals("rdv-5", op.serverId)
351 + assertNull(db.rdvDao().getByServerId("rdv-5"))
352 + }
353 +
354 + @Test
355 + fun agendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() = runBlocking {
356 + val bridge = FakeCalendarBridge()
357 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
358 + val localId = db.rdvDao().upsert(RdvEntity(titre = "RDV jamais poussé", calendarEventId = 77L, updatedAt = 1L))
359 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
360 + val coordinator = AgendaSyncCoordinator(db)
361 +
362 + coordinator.agendaToRoom(bindings, bridge)
363 +
364 + assertNull(db.rdvDao().getByLocalId(localId))
365 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" })
366 + }
367 +
368 + @Test
369 + fun agendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() = runBlocking {
370 + val bridge = FakeCalendarBridge()
371 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
372 + db.reservationDao().upsert(
373 + ReservationEntity(
374 + serverId = "resa-9",
375 + cibleType = "salle",
376 + cibleId = "s1",
377 + motif = "Réunion supprimée",
378 + calendarEventId = 55L,
379 + updatedAt = 1L,
380 + ),
381 + )
382 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
383 + val coordinator = AgendaSyncCoordinator(db)
384 +
385 + coordinator.agendaToRoom(bindings, bridge)
386 +
387 + val op = db.syncOpDao().listAll().single { it.entityType == "reservation" }
388 + assertEquals("delete", op.op)
389 + assertEquals("resa-9", op.serverId)
390 + assertNull(db.reservationDao().getByServerId("resa-9"))
391 + }
392 +
393 + @Test
394 + fun syncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() = runBlocking {
395 + val bridge = FakeCalendarBridge()
396 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
397 + db.reservationDao().upsert(
398 + ReservationEntity(
399 + serverId = "resa-9",
400 + cibleType = "salle",
401 + cibleId = "s1",
402 + motif = "Réunion supprimée",
403 + calendarEventId = 55L,
404 + updatedAt = 1L,
405 + ),
406 + )
407 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
408 +
409 + // agendaToRoom (via syncNow) supprime l'entité locale puis pushOps doit résoudre la cible via le payload de l'op.
410 + engine.syncNow(bindings, bridge)
411 +
412 + assertEquals(1, api.deleteReservationCalls.size)
413 + assertEquals(Triple("salle", "s1", "resa-9"), api.deleteReservationCalls.single())
414 + }
415 +
416 + @Test
417 + fun agendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() = runBlocking {
418 + val bridge = FakeCalendarBridge()
419 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
420 + db.reservationDao().upsert(
421 + ReservationEntity(
422 + serverId = "resa-conflict",
423 + cibleType = "salle",
424 + cibleId = "s1",
425 + motif = "En conflit",
426 + calendarEventId = 12L,
427 + updatedAt = 1L,
428 + conflictPending = true,
429 + ),
430 + )
431 + val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId))
432 + val coordinator = AgendaSyncCoordinator(db)
433 +
434 + coordinator.agendaToRoom(bindings, bridge)
435 +
436 + assertTrue(db.reservationDao().getByServerId("resa-conflict") != null)
437 + assertTrue(db.syncOpDao().listAll().none { it.entityType == "reservation" })
438 + }
439 +
440 + // ---- Fix MEDIUM : localAheadCount avec events orphelins ----
441 +
442 + @Test
443 + fun localAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() = runBlocking {
444 + db.rdvDao().upsert(RdvEntity(titre = "Dirty", dirtyLocal = true))
445 + db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "create", createdAt = 1L))
446 +
447 + val count = engine.localAheadCount()
448 +
449 + assertEquals(2, count)
450 + }
451 +
452 + @Test
453 + fun localAheadCount_withBridgeIncludesOrphanTaggedEvents() = runBlocking {
454 + val bridge = FakeCalendarBridge()
455 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
456 + bridge.seedEvent(
457 + calendarId,
458 + CalendarEventSnapshot(title = "Orphelin", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-orphan"),
459 + )
460 + val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId))
461 +
462 + val count = engine.localAheadCount(bindings, bridge)
463 +
464 + assertEquals(1, count)
465 + }
466 +
467 + // ---- Fix LOW : delete réservation sans serverId ----
468 +
469 + @Test
470 + fun pushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() = runBlocking {
471 + val localId = db.reservationDao().upsert(ReservationEntity(cibleType = "salle", cibleId = "s1", motif = "x"))
472 + db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "delete", localId = localId, createdAt = 1L))
473 +
474 + engine.syncNow()
475 +
476 + assertEquals(1, db.syncOpDao().listAll().size)
477 + }
478 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/AilianceApiClientTest.kt
+361 -0
@@ -0,0 +1,361 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import okhttp3.mockwebserver.MockResponse
4 +import okhttp3.mockwebserver.MockWebServer
5 +import org.junit.After
6 +import org.junit.Assert.assertEquals
7 +import org.junit.Assert.assertTrue
8 +import org.junit.Before
9 +import org.junit.Test
10 +
11 +class AilianceApiClientTest {
12 + private lateinit var server: MockWebServer
13 + private lateinit var client: AilianceApiClient
14 +
15 + @Before
16 + fun setUp() {
17 + server = MockWebServer()
18 + server.start()
19 + client = AilianceApiClient(
20 + baseUrl = server.url("/").toString().trimEnd('/'),
21 + apiKey = "test-bearer-key",
22 + )
23 + }
24 +
25 + @After
26 + fun tearDown() {
27 + server.shutdown()
28 + }
29 +
30 + @Test
31 + fun authCle200ParsesResponse() {
32 + server.enqueue(
33 + MockResponse()
34 + .setResponseCode(200)
35 + .setBody(
36 + """
37 + {
38 + "nom": "alice",
39 + "cle_chiffree": "Y2lw",
40 + "sel": "c2Vs",
41 + "kdf": "argon2id",
42 + "cipher": "xchacha20poly1305"
43 + }
44 + """.trimIndent(),
45 + ),
46 + )
47 +
48 + val result = client.authCle("alice", "secret")
49 +
50 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
51 + val ok = result as AilianceApiClient.ApiResult.Ok
52 + assertEquals("alice", ok.value.nom)
53 + assertEquals("Y2lw", ok.value.cleChiffree)
54 + assertEquals("c2Vs", ok.value.sel)
55 + assertEquals("argon2id", ok.value.kdf)
56 + assertEquals("xchacha20poly1305", ok.value.cipher)
57 +
58 + val request = server.takeRequest()
59 + assertEquals("POST", request.method)
60 + assertEquals("/api/auth/cle", request.path)
61 + assertTrue(request.getHeader("Authorization") == null)
62 + }
63 +
64 + @Test
65 + fun authCle401ReturnsErr() {
66 + server.enqueue(
67 + MockResponse()
68 + .setResponseCode(401)
69 + .setBody("""{"message":"Identifiants invalides."}"""),
70 + )
71 +
72 + val result = client.authCle("alice", "wrong")
73 +
74 + assertTrue(result is AilianceApiClient.ApiResult.Err)
75 + val err = result as AilianceApiClient.ApiResult.Err
76 + assertEquals(401, err.code)
77 + assertEquals("Identifiants invalides.", err.message)
78 + }
79 +
80 + @Test
81 + fun syncStatus200ParsesTotalAndChanges() {
82 + server.enqueue(
83 + MockResponse()
84 + .setResponseCode(200)
85 + .setBody(
86 + """
87 + {
88 + "server_time": "2026-07-22T12:00:00Z",
89 + "changes": {
90 + "contacts": 2,
91 + "entreprises": 0,
92 + "projets": 1,
93 + "taches": 3,
94 + "interactions": 1,
95 + "tombstones": 1
96 + },
97 + "total": 8
98 + }
99 + """.trimIndent(),
100 + ),
101 + )
102 +
103 + val result = client.syncStatus("1970-01-01T00:00:00Z")
104 +
105 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
106 + val ok = result as AilianceApiClient.ApiResult.Ok
107 + assertEquals("2026-07-22T12:00:00Z", ok.value.serverTime)
108 + assertEquals(8, ok.value.total)
109 + assertEquals(2, ok.value.changes.contacts)
110 + assertEquals(1, ok.value.changes.tombstones)
111 +
112 + val request = server.takeRequest()
113 + assertEquals("GET", request.method)
114 + assertEquals("/api/sync/status?since=1970-01-01T00%3A00%3A00Z", request.path)
115 + assertEquals("Bearer test-bearer-key", request.getHeader("Authorization"))
116 + }
117 +
118 + @Test
119 + fun syncPull200ParsesContactsAndTombstones() {
120 + server.enqueue(
121 + MockResponse()
122 + .setResponseCode(200)
123 + .setBody(
124 + """
125 + {
126 + "server_time": "2026-07-22T12:05:00Z",
127 + "contacts": [
128 + {
129 + "id": "c1",
130 + "prenom": "Jean",
131 + "nom": "Dupont",
132 + "cree_le": "2026-07-20T10:00:00Z",
133 + "mis_a_jour_le": "2026-07-21T08:00:00Z"
134 + }
135 + ],
136 + "entreprises": [],
137 + "projets": [],
138 + "taches": [
139 + {
140 + "projet_id": "p1",
141 + "id": "t1",
142 + "titre": "Appeler client",
143 + "colonne_id": "col-a-faire",
144 + "ordre": 0,
145 + "cree_le": "2026-07-21T09:00:00Z",
146 + "assigne_a": "alice"
147 + }
148 + ],
149 + "interactions": [],
150 + "tombstones": [
151 + {
152 + "type": "contact",
153 + "id": "c-old",
154 + "supprime_le": "2026-07-22T11:00:00Z"
155 + }
156 + ],
157 + "workflows": []
158 + }
159 + """.trimIndent(),
160 + ),
161 + )
162 +
163 + val result = client.syncPull("2026-07-20T00:00:00Z")
164 +
165 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
166 + val ok = result as AilianceApiClient.ApiResult.Ok
167 + assertEquals(1, ok.value.contacts.size)
168 + assertEquals("c1", ok.value.contacts[0].id)
169 + assertEquals("Jean", ok.value.contacts[0].prenom)
170 + assertEquals(1, ok.value.taches.size)
171 + assertEquals("p1", ok.value.taches[0].projetId)
172 + assertEquals("alice", ok.value.taches[0].assigneA)
173 + assertEquals(1, ok.value.tombstones.size)
174 + assertEquals("contact", ok.value.tombstones[0].entityType)
175 + assertEquals("c-old", ok.value.tombstones[0].id)
176 + }
177 +
178 + @Test
179 + fun syncPull200ParsesAgendaFields() {
180 + server.enqueue(
181 + MockResponse()
182 + .setResponseCode(200)
183 + .setBody(
184 + """
185 + {
186 + "server_time": "2026-07-22T12:05:00Z",
187 + "contacts": [],
188 + "entreprises": [],
189 + "projets": [],
190 + "taches": [],
191 + "interactions": [],
192 + "rdv": [
193 + {
194 + "id": "rdv1",
195 + "titre": "Point client",
196 + "utilisateur": "alice",
197 + "debut": "2026-07-22T09:00:00Z",
198 + "fin": "2026-07-22T10:00:00Z",
199 + "cree_par": "alice",
200 + "cree_le": "2026-07-20T08:00:00Z"
201 + }
202 + ],
203 + "reservations": [
204 + {
205 + "id": "resa1",
206 + "cible": {"type": "salle", "id": "salle-a"},
207 + "debut": "2026-07-22T09:00:00Z",
208 + "fin": "2026-07-22T10:00:00Z",
209 + "motif": "Réunion",
210 + "statut": "active",
211 + "reserve_par": "alice",
212 + "cree_le": "2026-07-20T08:00:00Z"
213 + }
214 + ],
215 + "indisponibilites": [
216 + {
217 + "id": "indispo1",
218 + "cible": {"type": "vehicule", "id": "vehicule-a"},
219 + "nature": "entretien",
220 + "debut": "2026-07-22T09:00:00Z",
221 + "fin": "2026-07-22T10:00:00Z",
222 + "cree_par": "alice",
223 + "cree_le": "2026-07-20T08:00:00Z"
224 + }
225 + ],
226 + "tombstones": [],
227 + "workflows": []
228 + }
229 + """.trimIndent(),
230 + ),
231 + )
232 +
233 + val result = client.syncPull("2026-07-20T00:00:00Z")
234 +
235 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
236 + val ok = result as AilianceApiClient.ApiResult.Ok
237 + assertEquals(1, ok.value.rdv.size)
238 + assertEquals("Point client", ok.value.rdv[0].titre)
239 + assertEquals(1, ok.value.reservations.size)
240 + assertEquals("salle", ok.value.reservations[0].cible.type)
241 + assertEquals("salle-a", ok.value.reservations[0].cible.id)
242 + assertEquals(1, ok.value.indisponibilites.size)
243 + assertEquals("entretien", ok.value.indisponibilites[0].nature)
244 + }
245 +
246 + @Test
247 + fun createReservation409ReturnsErrWithCode() {
248 + server.enqueue(
249 + MockResponse()
250 + .setResponseCode(409)
251 + .setBody("""{"message":"Réservation refusée : période en conflit.","reclamable":true}"""),
252 + )
253 +
254 + val result = client.createReservation(
255 + "salle",
256 + "salle-a",
257 + """{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z","motif":"Réunion"}""",
258 + )
259 +
260 + assertTrue(result is AilianceApiClient.ApiResult.Err)
261 + val err = result as AilianceApiClient.ApiResult.Err
262 + assertEquals(409, err.code)
263 + assertEquals("Réservation refusée : période en conflit.", err.message)
264 +
265 + val request = server.takeRequest()
266 + assertEquals("POST", request.method)
267 + assertEquals("/api/ressources/salle/salle-a/reservations", request.path)
268 + }
269 +
270 + @Test
271 + fun getRessourcesCatalogue200ParsesCatalogue() {
272 + server.enqueue(
273 + MockResponse()
274 + .setResponseCode(200)
275 + .setBody(
276 + """
277 + {
278 + "salles": [{"id": "salle-a", "nom": "Salle A", "actif": true}],
279 + "materiels": [],
280 + "vehicules": [{"id": "vehicule-a", "nom": "Véhicule A", "actif": false}]
281 + }
282 + """.trimIndent(),
283 + ),
284 + )
285 +
286 + val result = client.getRessourcesCatalogue()
287 +
288 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
289 + val ok = result as AilianceApiClient.ApiResult.Ok
290 + assertEquals(1, ok.value.salles.size)
291 + assertEquals("Salle A", ok.value.salles[0].nom)
292 + assertTrue(ok.value.salles[0].actif)
293 + assertEquals(0, ok.value.materiels.size)
294 + assertEquals(1, ok.value.vehicules.size)
295 + assertTrue(!ok.value.vehicules[0].actif)
296 +
297 + val request = server.takeRequest()
298 + assertEquals("GET", request.method)
299 + assertEquals("/api/ressources", request.path)
300 + }
301 +
302 + @Test
303 + fun protectedEndpointWithoutBearerReturns401() {
304 + val unauthenticated = AilianceApiClient(
305 + baseUrl = server.url("/").toString().trimEnd('/'),
306 + apiKey = null,
307 + )
308 + server.enqueue(
309 + MockResponse()
310 + .setResponseCode(401)
311 + .setBody("""{"message":"Non autorisé."}"""),
312 + )
313 +
314 + val result = unauthenticated.syncStatus("1970-01-01T00:00:00Z")
315 +
316 + assertTrue(result is AilianceApiClient.ApiResult.Err)
317 + val err = result as AilianceApiClient.ApiResult.Err
318 + assertEquals(401, err.code)
319 +
320 + val request = server.takeRequest()
321 + assertTrue(request.getHeader("Authorization") == null)
322 + }
323 +
324 + @Test
325 + fun uploadContactCarte_sendsMultipartWithBearer() {
326 + server.enqueue(
327 + MockResponse()
328 + .setResponseCode(200)
329 + .setBody("""{"id":"c1","carte_visite":"carte.jpg","cree_le":"2026-07-22T00:00:00Z"}"""),
330 + )
331 +
332 + val result = client.uploadContactCarte("c1", byteArrayOf(1, 2, 3), "card.jpg")
333 +
334 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
335 + val request = server.takeRequest()
336 + assertEquals("POST", request.method)
337 + assertEquals("/api/contacts/c1/carte", request.path)
338 + assertEquals("Bearer test-bearer-key", request.getHeader("Authorization"))
339 + val body = request.body.readUtf8()
340 + assertTrue(body.contains("filename=\"card.jpg\""))
341 + assertTrue(request.getHeader("Content-Type")!!.startsWith("multipart/form-data"))
342 + }
343 +
344 + @Test
345 + fun downloadContactCarte_returnsBytes() {
346 + server.enqueue(
347 + MockResponse()
348 + .setResponseCode(200)
349 + .setHeader("Content-Type", "image/jpeg")
350 + .setBody(okio.Buffer().write(byteArrayOf(10, 20, 30))),
351 + )
352 +
353 + val result = client.downloadContactCarte("c1")
354 +
355 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
356 + assertTrue((result as AilianceApiClient.ApiResult.Ok).value.contentEquals(byteArrayOf(10, 20, 30)))
357 + val request = server.takeRequest()
358 + assertEquals("GET", request.method)
359 + assertEquals("/api/contacts/c1/carte", request.path)
360 + }
361 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/ApiCleCryptoTest.kt
+54 -0
@@ -0,0 +1,54 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +
6 +class ApiCleCryptoTest {
7 + @Test
8 + fun decryptRustFixture() {
9 + val key = ApiCleCrypto.decryptApiKey(
10 + password = FIXTURE_MDP,
11 + saltB64 = FIXTURE_SEL_B64,
12 + cipherB64 = FIXTURE_BLOB_B64,
13 + )
14 + assertEquals(FIXTURE_CLE, key)
15 + }
16 +
17 + @Test
18 + fun wrongPasswordFails() {
19 + try {
20 + ApiCleCrypto.decryptApiKey(
21 + password = "wrong-password",
22 + saltB64 = FIXTURE_SEL_B64,
23 + cipherB64 = FIXTURE_BLOB_B64,
24 + )
25 + error("expected ApiCleCryptoException")
26 + } catch (_: ApiCleCryptoException) {
27 + // expected
28 + }
29 + }
30 +
31 + @Test
32 + fun blobTooShortFails() {
33 + try {
34 + ApiCleCrypto.decryptApiKey(
35 + password = FIXTURE_MDP,
36 + saltB64 = FIXTURE_SEL_B64,
37 + cipherB64 = SHORT_BLOB_B64,
38 + )
39 + error("expected ApiCleCryptoException")
40 + } catch (_: ApiCleCryptoException) {
41 + // expected
42 + }
43 + }
44 +
45 + private companion object {
46 + const val FIXTURE_MDP = "card2vcf-fixture-mdp"
47 + const val FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
48 + const val FIXTURE_SEL_B64 = "Fc+EFikMN7Cb8YoNZB+SZw=="
49 + const val FIXTURE_BLOB_B64 =
50 + "uW9uB9fP7m5ng6PoH9PGCXBKdPoVj9SceWy9GWLjg4x3Zk7MirbHhSCBVyLzoL2tK4qDir7UCOn503+s9rUFvIT/Eg5NqaU="
51 + // 23 bytes (< 24 nonce minimum)
52 + const val SHORT_BLOB_B64 = "AAAAAAAAAAAAAAAAAAAAAAA="
53 + }
54 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBindingsStoreTest.kt
+111 -0
@@ -0,0 +1,111 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import org.junit.Assert.assertEquals
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Before
8 +import org.junit.Test
9 +import org.junit.runner.RunWith
10 +import org.robolectric.RobolectricTestRunner
11 +import org.robolectric.annotation.Config
12 +
13 +@RunWith(RobolectricTestRunner::class)
14 +@Config(sdk = [31])
15 +class CalendarBindingsStoreTest {
16 + private lateinit var store: CalendarBindingsStore
17 +
18 + @Before
19 + fun setUp() {
20 + val ctx = ApplicationProvider.getApplicationContext<Context>()
21 + val prefs = ctx.getSharedPreferences("${CalendarBindingsStore.PREFS_NAME}_test", Context.MODE_PRIVATE)
22 + prefs.edit().clear().apply()
23 + store = CalendarBindingsStore(prefs)
24 + }
25 +
26 + @Test
27 + fun initiallyEmpty() {
28 + assertTrue(store.list().isEmpty())
29 + }
30 +
31 + @Test
32 + fun upsertRoundTrip() {
33 + val binding = CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = 42L)
34 +
35 + store.upsert(binding)
36 +
37 + assertEquals(listOf(binding), store.list())
38 + }
39 +
40 + @Test
41 + fun upsertReplacesExistingBindingWithSameKindAndServerResourceId() {
42 + val original = CalendarBinding(
43 + kind = "salle",
44 + serverResourceId = "salle-1",
45 + displayName = "Salle A",
46 + androidCalendarId = 1L,
47 + )
48 + val renamed = original.copy(displayName = "Salle A (renommée)", androidCalendarId = 2L)
49 +
50 + store.upsert(original)
51 + store.upsert(renamed)
52 +
53 + assertEquals(listOf(renamed), store.list())
54 + }
55 +
56 + @Test
57 + fun upsertKeepsDistinctBindingsByKindAndServerResourceId() {
58 + val rdv = CalendarBinding(kind = "rdv", displayName = "Mes RDV")
59 + val salle = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
60 + val materiel = CalendarBinding(kind = "materiel", serverResourceId = "materiel-1", displayName = "Matériel A")
61 +
62 + store.upsert(rdv)
63 + store.upsert(salle)
64 + store.upsert(materiel)
65 +
66 + assertEquals(setOf(rdv, salle, materiel), store.list().toSet())
67 + assertEquals(3, store.list().size)
68 + }
69 +
70 + @Test
71 + fun removeDeletesMatchingBinding() {
72 + val salle = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
73 + store.upsert(salle)
74 +
75 + store.remove(kind = "salle", serverResourceId = "salle-1")
76 +
77 + assertTrue(store.list().isEmpty())
78 + }
79 +
80 + @Test
81 + fun removeOnlyAffectsMatchingKindAndServerResourceId() {
82 + val salleA = CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A")
83 + val salleB = CalendarBinding(kind = "salle", serverResourceId = "salle-2", displayName = "Salle B")
84 + store.upsert(salleA)
85 + store.upsert(salleB)
86 +
87 + store.remove(kind = "salle", serverResourceId = "salle-1")
88 +
89 + assertEquals(listOf(salleB), store.list())
90 + }
91 +
92 + @Test
93 + fun removeWithNullServerResourceIdTargetsRdvBinding() {
94 + val rdv = CalendarBinding(kind = "rdv", displayName = "Mes RDV")
95 + store.upsert(rdv)
96 +
97 + store.remove(kind = "rdv", serverResourceId = null)
98 +
99 + assertTrue(store.list().isEmpty())
100 + }
101 +
102 + @Test
103 + fun clearRemovesAllBindings() {
104 + store.upsert(CalendarBinding(kind = "rdv", displayName = "Mes RDV"))
105 + store.upsert(CalendarBinding(kind = "salle", serverResourceId = "salle-1", displayName = "Salle A"))
106 +
107 + store.clear()
108 +
109 + assertTrue(store.list().isEmpty())
110 + }
111 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBridgeTest.kt
+205 -0
@@ -0,0 +1,205 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.ContentProvider
4 +import android.content.ContentResolver
5 +import android.content.ContentUris
6 +import android.content.ContentValues
7 +import android.content.Context
8 +import android.content.UriMatcher
9 +import android.content.pm.ProviderInfo
10 +import android.database.Cursor
11 +import android.database.MatrixCursor
12 +import android.net.Uri
13 +import android.provider.CalendarContract
14 +import androidx.test.core.app.ApplicationProvider
15 +import org.junit.Assert.assertEquals
16 +import org.junit.Assert.assertTrue
17 +import org.junit.Before
18 +import org.junit.Test
19 +import org.junit.runner.RunWith
20 +import org.robolectric.Robolectric
21 +import org.robolectric.RobolectricTestRunner
22 +import org.robolectric.annotation.Config
23 +
24 +/**
25 + * Couvre [CalendarBridge] au-delà des fonctions pures déjà testées dans
26 + * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`listEvents`/`upsertEvent`/`deleteEvent`
27 + * via un `ContentProvider` en mémoire ([FakeCalendarProvider]), le vrai fournisseur Agenda
28 + * n'étant pas embarqué dans `android-all` sous Robolectric.
29 + */
30 +@RunWith(RobolectricTestRunner::class)
31 +@Config(sdk = [31])
32 +class CalendarBridgeTest {
33 + private lateinit var bridge: CalendarBridge
34 +
35 + @Before
36 + fun setUp() {
37 + val providerInfo = ProviderInfo().apply { authority = CalendarContract.AUTHORITY }
38 + Robolectric.buildContentProvider(FakeCalendarProvider::class.java).create(providerInfo)
39 +
40 + val resolver = ApplicationProvider.getApplicationContext<Context>().contentResolver
41 + bridge = CalendarBridge(resolver)
42 + }
43 +
44 + @Test
45 + fun ensureLocalCalendarCreatesThenReusesSameCalendar() {
46 + val firstId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
47 + val secondId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
48 +
49 + assertEquals(firstId, secondId)
50 + }
51 +
52 + @Test
53 + fun ensureLocalCalendarCreatesDistinctCalendarsForDistinctNames() {
54 + val rdvId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
55 + val salleId = bridge.ensureLocalCalendar("Card2vcf — Salle A")
56 +
57 + assertTrue(rdvId != salleId)
58 + }
59 +
60 + @Test
61 + fun listEventsOnEmptyCalendarIsEmpty() {
62 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
63 +
64 + assertTrue(bridge.listEvents(calendarId).isEmpty())
65 + }
66 +
67 + @Test
68 + fun upsertEventInsertsThenListEventsReturnsIt() {
69 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
70 + val snapshot = CalendarEventSnapshot(
71 + title = "RDV client",
72 + debutMs = 1_000L,
73 + finMs = 2_000L,
74 + serverId = "rdv-42",
75 + descriptionBody = "notes libres",
76 + )
77 +
78 + val eventId = bridge.upsertEvent(calendarId, snapshot)
79 +
80 + assertEquals(listOf(snapshot.copy(eventId = eventId)), bridge.listEvents(calendarId))
81 + }
82 +
83 + @Test
84 + fun upsertEventWithExistingEventIdUpdatesInPlace() {
85 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
86 + val eventId = bridge.upsertEvent(
87 + calendarId,
88 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-42"),
89 + )
90 +
91 + val updatedId = bridge.upsertEvent(
92 + calendarId,
93 + CalendarEventSnapshot(
94 + title = "RDV client (déplacé)",
95 + debutMs = 3_000L,
96 + finMs = 4_000L,
97 + eventId = eventId,
98 + serverId = "rdv-42",
99 + ),
100 + )
101 +
102 + assertEquals(eventId, updatedId)
103 + val events = bridge.listEvents(calendarId)
104 + assertEquals(1, events.size)
105 + assertEquals("RDV client (déplacé)", events.single().title)
106 + assertEquals(3_000L, events.single().debutMs)
107 + }
108 +
109 + @Test
110 + fun deleteEventRemovesItFromListEvents() {
111 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
112 + val eventId = bridge.upsertEvent(
113 + calendarId,
114 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L),
115 + )
116 +
117 + bridge.deleteEvent(eventId)
118 +
119 + assertTrue(bridge.listEvents(calendarId).isEmpty())
120 + }
121 +}
122 +
123 +/**
124 + * Fournisseur Agenda minimal en mémoire, pour tester [CalendarBridge] sous Robolectric
125 + * (le vrai `CalendarProvider2` de l'app Agenda n'est pas embarqué dans `android-all`).
126 + * Ne supporte que les projections/sélections utilisées par [CalendarBridge].
127 + */
128 +class FakeCalendarProvider : ContentProvider() {
129 + private val calendars = mutableMapOf<Long, ContentValues>()
130 + private val events = mutableMapOf<Long, ContentValues>()
131 + private var nextId = 1L
132 +
133 + override fun onCreate(): Boolean = true
134 +
135 + override fun getType(uri: Uri): String? = null
136 +
137 + override fun insert(uri: Uri, values: ContentValues?): Uri {
138 + val id = nextId++
139 + val row = ContentValues(values).apply { put("_id", id) }
140 + when (matcher.match(uri)) {
141 + CALENDARS -> calendars[id] = row
142 + EVENTS -> events[id] = row
143 + else -> error("Uri non supportée par FakeCalendarProvider : $uri")
144 + }
145 + return ContentUris.withAppendedId(baseUriFor(matcher.match(uri)), id)
146 + }
147 +
148 + override fun query(
149 + uri: Uri,
150 + projection: Array<out String>?,
151 + selection: String?,
152 + selectionArgs: Array<out String>?,
153 + sortOrder: String?,
154 + ): Cursor {
155 + val table = when (matcher.match(uri)) {
156 + CALENDARS -> calendars
157 + EVENTS -> events
158 + else -> error("Uri non supportée par FakeCalendarProvider : $uri")
159 + }
160 + val columns = projection ?: arrayOf("_id")
161 + val cursor = MatrixCursor(columns)
162 + table.toSortedMap().values
163 + .filter { matchesSelection(it, selection, selectionArgs) }
164 + .forEach { row -> cursor.addRow(columns.map { column -> row.get(column) }) }
165 + return cursor
166 + }
167 +
168 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
169 + val id = ContentUris.parseId(uri)
170 + val row = events[id] ?: return 0
171 + values?.let { row.putAll(it) }
172 + return 1
173 + }
174 +
175 + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
176 + val id = ContentUris.parseId(uri)
177 + return if (events.remove(id) != null) 1 else 0
178 + }
179 +
180 + private fun baseUriFor(matchCode: Int): Uri = when (matchCode) {
181 + CALENDARS -> Uri.parse("content://${CalendarContract.AUTHORITY}/calendars")
182 + EVENTS -> Uri.parse("content://${CalendarContract.AUTHORITY}/events")
183 + else -> error("Code de correspondance inconnu : $matchCode")
184 + }
185 +
186 + private fun matchesSelection(row: ContentValues, selection: String?, args: Array<out String>?): Boolean {
187 + if (selection.isNullOrBlank()) return true
188 + return selection.split(" AND ").withIndex().all { (index, condition) ->
189 + val column = condition.substringBefore(" = ?").trim()
190 + row.get(column)?.toString() == args?.getOrNull(index)
191 + }
192 + }
193 +
194 + private companion object {
195 + const val CALENDARS = 1
196 + const val EVENTS = 2
197 +
198 + val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
199 + addURI(CalendarContract.AUTHORITY, "calendars", CALENDARS)
200 + addURI(CalendarContract.AUTHORITY, "calendars/#", CALENDARS)
201 + addURI(CalendarContract.AUTHORITY, "events", EVENTS)
202 + addURI(CalendarContract.AUTHORITY, "events/#", EVENTS)
203 + }
204 + }
205 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarServerIdCodecTest.kt
+104 -0
@@ -0,0 +1,104 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertNull
5 +import org.junit.Test
6 +
7 +class CalendarServerIdCodecTest {
8 +
9 + @Test
10 + fun encodeWithServerIdPrependsPrefixLine() {
11 + val encoded = CalendarServerIdCodec.encode(serverId = "abc-123", descriptionBody = "RDV client")
12 +
13 + assertEquals("card2vcf:serverId=abc-123\nRDV client", encoded)
14 + }
15 +
16 + @Test
17 + fun encodeWithNullServerIdReturnsBodyUnchanged() {
18 + val encoded = CalendarServerIdCodec.encode(serverId = null, descriptionBody = "RDV client")
19 +
20 + assertEquals("RDV client", encoded)
21 + }
22 +
23 + @Test
24 + fun encodeWithEmptyBodyKeepsTrailingNewline() {
25 + val encoded = CalendarServerIdCodec.encode(serverId = "abc-123", descriptionBody = "")
26 +
27 + assertEquals("card2vcf:serverId=abc-123\n", encoded)
28 + }
29 +
30 + @Test
31 + fun decodeExtractsServerIdAndBody() {
32 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nRDV client")
33 +
34 + assertEquals("abc-123", serverId)
35 + assertEquals("RDV client", body)
36 + }
37 +
38 + @Test
39 + fun decodeWithoutPrefixReturnsNullServerIdAndFullDescription() {
40 + val (serverId, body) = CalendarServerIdCodec.decode("Simple description sans serverId")
41 +
42 + assertNull(serverId)
43 + assertEquals("Simple description sans serverId", body)
44 + }
45 +
46 + @Test
47 + fun decodeNullDescriptionReturnsNullAndEmptyBody() {
48 + val (serverId, body) = CalendarServerIdCodec.decode(null)
49 +
50 + assertNull(serverId)
51 + assertEquals("", body)
52 + }
53 +
54 + @Test
55 + fun decodeWithPrefixButNoNewlineTreatsRestAsServerId() {
56 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123")
57 +
58 + assertEquals("abc-123", serverId)
59 + assertEquals("", body)
60 + }
61 +
62 + @Test
63 + fun decodeWithMultilineBodyPreservesInternalNewlines() {
64 + val (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nligne1\nligne2")
65 +
66 + assertEquals("abc-123", serverId)
67 + assertEquals("ligne1\nligne2", body)
68 + }
69 +
70 + @Test
71 + fun roundTripEncodeThenDecodePreservesServerIdAndBody() {
72 + val encoded = CalendarServerIdCodec.encode(serverId = "srv-42", descriptionBody = "Détails\nsur plusieurs lignes")
73 + val (serverId, body) = CalendarServerIdCodec.decode(encoded)
74 +
75 + assertEquals("srv-42", serverId)
76 + assertEquals("Détails\nsur plusieurs lignes", body)
77 + }
78 +
79 + @Test
80 + fun roundTripWithoutServerIdPreservesBody() {
81 + val encoded = CalendarServerIdCodec.encode(serverId = null, descriptionBody = "Sans serverId")
82 + val (serverId, body) = CalendarServerIdCodec.decode(encoded)
83 +
84 + assertNull(serverId)
85 + assertEquals("Sans serverId", body)
86 + }
87 +
88 + @Test
89 + fun indispoTitleAddsPrefix() {
90 + assertEquals("[Indispo] Salle A fermée", CalendarEventTitles.indispoTitle("Salle A fermée"))
91 + }
92 +
93 + @Test
94 + fun isIndispoTitleDetectsPrefix() {
95 + assertEquals(true, CalendarEventTitles.isIndispoTitle("[Indispo] Salle A fermée"))
96 + assertEquals(false, CalendarEventTitles.isIndispoTitle("RDV client"))
97 + }
98 +
99 + @Test
100 + fun stripIndispoPrefixRemovesPrefixWhenPresent() {
101 + assertEquals("Salle A fermée", CalendarEventTitles.stripIndispoPrefix("[Indispo] Salle A fermée"))
102 + assertEquals("RDV client", CalendarEventTitles.stripIndispoPrefix("RDV client"))
103 + }
104 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/ContactSyncMapperTest.kt
+57 -0
@@ -0,0 +1,57 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlinx.serialization.decodeFromString
5 +import org.junit.Assert.assertEquals
6 +import org.junit.Assert.assertNull
7 +import org.junit.Test
8 +
9 +class ContactSyncMapperTest {
10 + @Test
11 + fun mapsLocalFieldsToApiJson() {
12 + val entity = CrmContactEntity(
13 + id = 1L,
14 + fullName = "Marie Dubois",
15 + firstName = "Marie",
16 + lastName = "Dubois",
17 + jobTitle = "Directrice",
18 + phones = listOf("+33 1 23 45 67 89"),
19 + emails = listOf("marie@acme.fr"),
20 + notes = "Rencontrée au salon",
21 + entrepriseServerId = "ent-42",
22 + statut = "client",
23 + etape = "qualifie",
24 + tags = listOf("vip", "lyon"),
25 + )
26 +
27 + val payload = ContactSyncMapper.toCreatePayload(entity)
28 + val request = syncJson.decodeFromString<CreateContactRequest>(payload)
29 +
30 + assertEquals("Marie", request.prenom)
31 + assertEquals("Dubois", request.nom)
32 + assertEquals("ent-42", request.entrepriseId)
33 + assertEquals("Directrice", request.fonction)
34 + assertEquals("marie@acme.fr", request.emails.single().valeur)
35 + assertEquals("+33 1 23 45 67 89", request.telephones.single().valeur)
36 + assertEquals("Rencontrée au salon", request.notes)
37 + assertEquals("client", request.statut)
38 + assertEquals("qualifie", request.etape)
39 + assertEquals(listOf("vip", "lyon"), request.tags)
40 + }
41 +
42 + @Test
43 + fun splitsFullNameWhenFirstAndLastMissing() {
44 + val entity = CrmContactEntity(
45 + fullName = "Jean Paul Sartre",
46 + emails = listOf("jp@ex.com"),
47 + )
48 +
49 + val request = syncJson.decodeFromString<CreateContactRequest>(
50 + ContactSyncMapper.toCreatePayload(entity),
51 + )
52 +
53 + assertEquals("Jean", request.prenom)
54 + assertEquals("Paul Sartre", request.nom)
55 + assertNull(request.entrepriseId)
56 + }
57 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+124 -0
@@ -0,0 +1,124 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. Partagé par [SyncEngineTest] et [AgendaSyncEngineTest]. */
4 +class FakeAilianceApi : AilianceApi {
5 + var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
6 + AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0))
7 + var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
8 + AilianceApiClient.ApiResult.Ok(SyncPullResponse(serverTime = "1970-01-01T00:00:00Z"))
9 + var createContactResult: AilianceApiClient.ApiResult<String> =
10 + AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""")
11 + val createContactCalls = mutableListOf<String>()
12 +
13 + var createReservationResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"resa-generated"}""")
14 + var updateReservationResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
15 + val createReservationCalls = mutableListOf<Triple<String, String, String>>()
16 +
17 + var createRdvResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("""{"id":"rdv-generated"}""")
18 + val createRdvCalls = mutableListOf<String>()
19 +
20 + var statusQueries = mutableListOf<String?>()
21 + var pullQueries = mutableListOf<String?>()
22 +
23 + override fun authCle(nom: String, motDePasse: String) =
24 + AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
25 +
26 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncStatusResponse> {
27 + statusQueries += ressourcesQuery
28 + return statusResult
29 + }
30 +
31 + override fun syncPull(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncPullResponse> {
32 + pullQueries += ressourcesQuery
33 + return pullResult
34 + }
35 +
36 + override fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String> {
37 + createContactCalls += jsonBody
38 + return createContactResult
39 + }
40 +
41 + override fun updateContact(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
42 + override fun deleteContact(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
43 + override fun createEntreprise(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
44 + override fun updateEntreprise(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
45 + override fun deleteEntreprise(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
46 + override fun createProjet(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
47 + override fun updateProjet(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
48 + override fun deleteProjet(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
49 + override fun createTache(projetId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
50 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
51 + override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
52 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
53 + override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
54 + override fun listRdv() = AilianceApiClient.ApiResult.Ok(emptyList<RendezVousDto>())
55 +
56 + override fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String> {
57 + createRdvCalls += jsonBody
58 + return createRdvResult
59 + }
60 +
61 + override fun getRdv(id: String) = error("not used")
62 + override fun updateRdv(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
63 + override fun deleteRdv(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
64 + override fun getRessourcesCatalogue() = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto())
65 + override fun listReservations(genre: String, resourceId: String) = AilianceApiClient.ApiResult.Ok(emptyList<ReservationDto>())
66 +
67 + override fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
68 + createReservationCalls += Triple(genre, resourceId, jsonBody)
69 + return createReservationResult
70 + }
71 +
72 + override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) =
73 + updateReservationResult
74 +
75 + val deleteReservationCalls = mutableListOf<Triple<String, String, String>>()
76 +
77 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String): AilianceApiClient.ApiResult<Unit> {
78 + deleteReservationCalls += Triple(genre, resourceId, reservationId)
79 + return AilianceApiClient.ApiResult.Ok(Unit)
80 + }
81 + override fun listIndisponibilites(genre: String, resourceId: String) =
82 + AilianceApiClient.ApiResult.Ok(emptyList<IndisponibiliteDto>())
83 +
84 + var uploadCarteResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
85 + var uploadPhotoResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
86 + val uploadCarteCalls = mutableListOf<Pair<String, Int>>()
87 + val uploadPhotoCalls = mutableListOf<Pair<String, Int>>()
88 + var downloadCarteResult: AilianceApiClient.ApiResult<ByteArray> =
89 + AilianceApiClient.ApiResult.Err(404, "absent")
90 + var downloadPhotoResult: AilianceApiClient.ApiResult<ByteArray> =
91 + AilianceApiClient.ApiResult.Err(404, "absent")
92 + val downloadCarteCalls = mutableListOf<String>()
93 + val downloadPhotoCalls = mutableListOf<String>()
94 +
95 + override fun uploadContactCarte(
96 + id: String,
97 + bytes: ByteArray,
98 + filename: String,
99 + contentType: String,
100 + ): AilianceApiClient.ApiResult<String> {
101 + uploadCarteCalls += id to bytes.size
102 + return uploadCarteResult
103 + }
104 +
105 + override fun uploadContactPhoto(
106 + id: String,
107 + bytes: ByteArray,
108 + filename: String,
109 + contentType: String,
110 + ): AilianceApiClient.ApiResult<String> {
111 + uploadPhotoCalls += id to bytes.size
112 + return uploadPhotoResult
113 + }
114 +
115 + override fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray> {
116 + downloadCarteCalls += id
117 + return downloadCarteResult
118 + }
119 +
120 + override fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray> {
121 + downloadPhotoCalls += id
122 + return downloadPhotoResult
123 + }
124 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/FakeCalendarBridge.kt
+31 -0
@@ -0,0 +1,31 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Fake [CalendarBridgeApi] en mémoire (sans `ContentResolver`) pour tester le pont Agenda↔Room. */
4 +class FakeCalendarBridge : CalendarBridgeApi {
5 + private val calendars = mutableMapOf<String, Long>()
6 + private val events = mutableMapOf<Long, MutableMap<Long, CalendarEventSnapshot>>()
7 + private var nextCalendarId = 1L
8 + private var nextEventId = 1L
9 + val deletedEventIds = mutableListOf<Long>()
10 +
11 + override fun ensureLocalCalendar(displayName: String): Long =
12 + calendars.getOrPut(displayName) { nextCalendarId++ }
13 +
14 + override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> =
15 + events[calendarId]?.values?.toList() ?: emptyList()
16 +
17 + override fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long {
18 + val calendarEvents = events.getOrPut(calendarId) { mutableMapOf() }
19 + val eventId = snap.eventId ?: nextEventId++
20 + calendarEvents[eventId] = snap.copy(eventId = eventId)
21 + return eventId
22 + }
23 +
24 + override fun deleteEvent(eventId: Long) {
25 + deletedEventIds += eventId
26 + events.values.forEach { it.remove(eventId) }
27 + }
28 +
29 + /** Simule un événement déjà présent dans l'app Agenda système (ajout hors [upsertEvent]). */
30 + fun seedEvent(calendarId: Long, snap: CalendarEventSnapshot): Long = upsertEvent(calendarId, snap)
31 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/LwwMergerTest.kt
+119 -0
@@ -0,0 +1,119 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertFalse
5 +import org.junit.Assert.assertTrue
6 +import org.junit.Test
7 +
8 +class LwwMergerTest {
9 + @Test
10 + fun pickLww_remoteNewerWins() {
11 + val result = LwwMerger.pickLww(
12 + local = "local-value",
13 + localTs = 100L,
14 + remote = "remote-value",
15 + remoteTs = 200L,
16 + )
17 + assertEquals("remote-value", result)
18 + }
19 +
20 + @Test
21 + fun pickLww_localNewerWins() {
22 + val result = LwwMerger.pickLww(
23 + local = "local-value",
24 + localTs = 300L,
25 + remote = "remote-value",
26 + remoteTs = 200L,
27 + )
28 + assertEquals("local-value", result)
29 + }
30 +
31 + @Test
32 + fun pickLww_equalTimestampsRemoteWins() {
33 + val result = LwwMerger.pickLww(
34 + local = "local-value",
35 + localTs = 100L,
36 + remote = "remote-value",
37 + remoteTs = 100L,
38 + )
39 + assertEquals("remote-value", result)
40 + }
41 +
42 + @Test
43 + fun pickTaskMove_remoteNewerWins() {
44 + val local = LwwMerger.TaskPlacement(
45 + colonneId = "todo",
46 + ordre = 1,
47 + updatedAt = 100L,
48 + )
49 + val remote = LwwMerger.TaskPlacement(
50 + colonneId = "done",
51 + ordre = 2,
52 + updatedAt = 200L,
53 + )
54 +
55 + val result = LwwMerger.pickTaskMove(local, remote)
56 +
57 + assertEquals(remote, result)
58 + }
59 +
60 + @Test
61 + fun pickTaskMove_localNewerWins() {
62 + val local = LwwMerger.TaskPlacement(
63 + colonneId = "todo",
64 + ordre = 1,
65 + updatedAt = 300L,
66 + )
67 + val remote = LwwMerger.TaskPlacement(
68 + colonneId = "done",
69 + ordre = 2,
70 + updatedAt = 200L,
71 + )
72 +
73 + val result = LwwMerger.pickTaskMove(local, remote)
74 +
75 + assertEquals(local, result)
76 + }
77 +
78 + @Test
79 + fun pickTaskMove_equalTimestampsRemoteWins() {
80 + val local = LwwMerger.TaskPlacement(
81 + colonneId = "todo",
82 + ordre = 1,
83 + updatedAt = 100L,
84 + )
85 + val remote = LwwMerger.TaskPlacement(
86 + colonneId = "done",
87 + ordre = 2,
88 + updatedAt = 100L,
89 + )
90 +
91 + val result = LwwMerger.pickTaskMove(local, remote)
92 +
93 + assertEquals(remote, result)
94 + }
95 +
96 + @Test
97 + fun shouldInsertInteraction_whenUnknownServerId() {
98 + val existing = setOf("srv-1", "srv-2")
99 +
100 + assertTrue(LwwMerger.shouldInsertInteraction(existing, "srv-3"))
101 + }
102 +
103 + @Test
104 + fun shouldInsertInteraction_whenKnownServerId() {
105 + val existing = setOf("srv-1", "srv-2")
106 +
107 + assertFalse(LwwMerger.shouldInsertInteraction(existing, "srv-1"))
108 + }
109 +
110 + @Test
111 + fun applyTombstoneIds_removesMatchingIds() {
112 + val local = setOf("a", "b", "c")
113 + val tombstones = setOf("b", "d")
114 +
115 + val remaining = LwwMerger.applyTombstoneIds(local, tombstones)
116 +
117 + assertEquals(setOf("a", "c"), remaining)
118 + }
119 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/ServerUrlPolicyTest.kt
+74 -0
@@ -0,0 +1,74 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertFalse
5 +import org.junit.Assert.assertNull
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Test
8 +
9 +/** TDD : HTTPS obligatoire sauf option « autoriser HTTP clair ». */
10 +class ServerUrlPolicyTest {
11 +
12 + @Test
13 + fun https_url_is_allowed_by_default() {
14 + assertNull(ServerUrlPolicy.validate("https://api.example.com", allowCleartext = false))
15 + assertNull(ServerUrlPolicy.validate("HTTPS://API.EXAMPLE.COM/v1", allowCleartext = false))
16 + }
17 +
18 + @Test
19 + fun http_url_rejected_when_cleartext_disallowed() {
20 + val err = ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext = false)
21 + assertEquals(ServerUrlPolicy.ERROR_HTTPS_REQUIRED, err)
22 + }
23 +
24 + @Test
25 + fun http_url_allowed_when_cleartext_enabled() {
26 + assertNull(ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext = true))
27 + assertNull(ServerUrlPolicy.validate("http://localhost:3000", allowCleartext = true))
28 + }
29 +
30 + @Test
31 + fun blank_or_missing_scheme_rejected() {
32 + assertEquals(ServerUrlPolicy.ERROR_INVALID_URL, ServerUrlPolicy.validate(" ", allowCleartext = true))
33 + assertEquals(
34 + ServerUrlPolicy.ERROR_INVALID_URL,
35 + ServerUrlPolicy.validate("api.example.com", allowCleartext = false),
36 + )
37 + assertEquals(
38 + ServerUrlPolicy.ERROR_INVALID_URL,
39 + ServerUrlPolicy.validate("ftp://api.example.com", allowCleartext = true),
40 + )
41 + }
42 +
43 + @Test
44 + fun isHttps_helper() {
45 + assertTrue(ServerUrlPolicy.isHttps("https://x"))
46 + assertFalse(ServerUrlPolicy.isHttps("http://x"))
47 + assertFalse(ServerUrlPolicy.isHttps("x"))
48 + }
49 +
50 + @Test
51 + fun lan_https_without_port_rejected() {
52 + val err = ServerUrlPolicy.validate("https://192.168.1.35", allowCleartext = false)
53 + assertEquals(ServerUrlPolicy.ERROR_LAN_HTTPS_NO_PORT, err)
54 + }
55 +
56 + @Test
57 + fun lan_http_without_port_rejected_even_when_cleartext() {
58 + val err = ServerUrlPolicy.validate("http://192.168.1.35", allowCleartext = true)
59 + assertEquals(ServerUrlPolicy.ERROR_LAN_HTTP_NO_PORT, err)
60 + }
61 +
62 + @Test
63 + fun normalizeBaseUrl_strips_version_path() {
64 + assertEquals(
65 + "http://192.168.1.35:3000",
66 + ServerUrlPolicy.normalizeBaseUrl("http://192.168.1.35:3000/version/"),
67 + )
68 + }
69 +
70 + @Test
71 + fun public_https_without_port_still_allowed() {
72 + assertNull(ServerUrlPolicy.validate("https://api.example.com", allowCleartext = false))
73 + }
74 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncCredentialsStoreTest.kt
+102 -0
@@ -0,0 +1,102 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import org.junit.After
6 +import org.junit.Assert.assertEquals
7 +import org.junit.Assert.assertFalse
8 +import org.junit.Assert.assertNull
9 +import org.junit.Assert.assertTrue
10 +import org.junit.Before
11 +import org.junit.Test
12 +import org.junit.runner.RunWith
13 +import org.robolectric.RobolectricTestRunner
14 +import org.robolectric.annotation.Config
15 +
16 +@RunWith(RobolectricTestRunner::class)
17 +@Config(sdk = [31])
18 +class SyncCredentialsStoreTest {
19 + private lateinit var store: SyncCredentialsStore
20 +
21 + @Before
22 + fun setUp() {
23 + val ctx = ApplicationProvider.getApplicationContext<Context>()
24 + val prefs = ctx.getSharedPreferences("${SyncCredentialsStore.PREFS_NAME}_test", Context.MODE_PRIVATE)
25 + prefs.edit().clear().apply()
26 + store = SyncCredentialsStore(prefs)
27 + }
28 +
29 + @After
30 + fun tearDown() {
31 + store.clear()
32 + }
33 +
34 + @Test
35 + fun initiallyNotConfigured() {
36 + assertFalse(store.isConfigured())
37 + assertNull(store.baseUrl)
38 + assertNull(store.apiKey)
39 + assertNull(store.userName)
40 + }
41 +
42 + @Test
43 + fun saveRoundTrip() {
44 + store.save(
45 + baseUrl = "https://api.example.com",
46 + apiKey = "secret-bearer-token",
47 + userName = "alice",
48 + )
49 +
50 + assertTrue(store.isConfigured())
51 + assertEquals("https://api.example.com", store.baseUrl)
52 + assertEquals("secret-bearer-token", store.apiKey)
53 + assertEquals("alice", store.userName)
54 + }
55 +
56 + @Test
57 + fun propertySettersRoundTrip() {
58 + store.baseUrl = "https://sync.test"
59 + store.apiKey = "key-123"
60 + store.userName = "bob"
61 +
62 + assertTrue(store.isConfigured())
63 + assertEquals("https://sync.test", store.baseUrl)
64 + assertEquals("key-123", store.apiKey)
65 + assertEquals("bob", store.userName)
66 + }
67 +
68 + @Test
69 + fun clearRemovesAllCredentials() {
70 + store.save(
71 + baseUrl = "https://api.example.com",
72 + apiKey = "secret-bearer-token",
73 + userName = "alice",
74 + )
75 + store.allowCleartextHttp = true
76 +
77 + store.clear()
78 +
79 + assertFalse(store.isConfigured())
80 + assertNull(store.baseUrl)
81 + assertNull(store.apiKey)
82 + assertNull(store.userName)
83 + assertTrue(store.allowCleartextHttp)
84 + }
85 +
86 + @Test
87 + fun allowCleartextHttp_defaultsFalse_andRoundTrips() {
88 + assertFalse(store.allowCleartextHttp)
89 + store.allowCleartextHttp = true
90 + assertTrue(store.allowCleartextHttp)
91 + store.allowCleartextHttp = false
92 + assertFalse(store.allowCleartextHttp)
93 + }
94 +
95 + @Test
96 + fun blankValuesAreNotConfigured() {
97 + store.save(baseUrl = " ", apiKey = "key", userName = "alice")
98 +
99 + assertFalse(store.isConfigured())
100 + assertNull(store.baseUrl)
101 + }
102 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+264 -0
@@ -0,0 +1,264 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmContactEntity
7 +import fr.ebii.card2vcf.data.CrmDatabase
8 +import kotlinx.coroutines.runBlocking
9 +import org.junit.After
10 +import org.junit.Assert.assertEquals
11 +import org.junit.Assert.assertNull
12 +import org.junit.Assert.assertTrue
13 +import org.junit.Before
14 +import org.junit.Test
15 +import org.junit.runner.RunWith
16 +import org.robolectric.RobolectricTestRunner
17 +import org.robolectric.annotation.Config
18 +
19 +@RunWith(RobolectricTestRunner::class)
20 +@Config(sdk = [31])
21 +class SyncEngineTest {
22 + private lateinit var db: CrmDatabase
23 + private lateinit var api: FakeAilianceApi
24 + private lateinit var engine: SyncEngine
25 +
26 + @Before
27 + fun setUp() {
28 + val ctx = ApplicationProvider.getApplicationContext<Context>()
29 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
30 + .allowMainThreadQueries()
31 + .build()
32 + api = FakeAilianceApi()
33 + engine = SyncEngine(api, db)
34 + }
35 +
36 + @After
37 + fun tearDown() = db.close()
38 +
39 + @Test
40 + fun checkStatus_returnsTotalFromFake() = runBlocking {
41 + api.statusResult = AilianceApiClient.ApiResult.Ok(
42 + SyncStatusResponse(
43 + serverTime = "2026-07-22T12:00:00Z",
44 + changes = SyncChanges(contacts = 2, tombstones = 1),
45 + total = 3,
46 + ),
47 + )
48 +
49 + val result = engine.checkStatus()
50 +
51 + assertEquals(3, result.pendingRemoteChanges)
52 + assertEquals(3, result.response?.total)
53 + }
54 +
55 + @Test
56 + fun syncNow_pullAppliesNewerContact() = runBlocking {
57 + db.crmContactDao().insert(
58 + CrmContactEntity(
59 + serverId = "c1",
60 + fullName = "Old Name",
61 + updatedAt = 1_000L,
62 + ),
63 + )
64 + api.pullResult = AilianceApiClient.ApiResult.Ok(
65 + SyncPullResponse(
66 + serverTime = "2026-07-22T12:05:00Z",
67 + contacts = listOf(
68 + ContactDto(
69 + id = "c1",
70 + prenom = "Jean",
71 + nom = "Dupont",
72 + creeLe = "2026-07-20T10:00:00Z",
73 + misAJourLe = "2026-07-22T11:00:00Z",
74 + ),
75 + ),
76 + ),
77 + )
78 +
79 + engine.syncNow()
80 +
81 + val updated = db.crmContactDao().getByServerId("c1")
82 + assertEquals("Jean Dupont", updated?.fullName)
83 + }
84 +
85 + @Test
86 + fun syncNow_pullKeepsBothAppendOnlyInteractions() = runBlocking {
87 + api.pullResult = AilianceApiClient.ApiResult.Ok(
88 + SyncPullResponse(
89 + serverTime = "2026-07-22T12:05:00Z",
90 + interactions = listOf(
91 + InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"),
92 + InteractionDto(id = "i2", contactId = "c1", sujet = "Relance", creeLe = "2026-07-22T10:00:00Z"),
93 + ),
94 + ),
95 + )
96 +
97 + engine.syncNow()
98 +
99 + val stored = db.interactionDao().listByContactServerId("c1")
100 + assertEquals(2, stored.size)
101 + assertTrue(stored.any { it.serverId == "i1" })
102 + assertTrue(stored.any { it.serverId == "i2" })
103 + }
104 +
105 + @Test
106 + fun syncNow_tombstoneDeletesContact() = runBlocking {
107 + db.crmContactDao().insert(
108 + CrmContactEntity(serverId = "c-old", fullName = "À supprimer", updatedAt = 500L),
109 + )
110 + api.pullResult = AilianceApiClient.ApiResult.Ok(
111 + SyncPullResponse(
112 + serverTime = "2026-07-22T12:05:00Z",
113 + tombstones = listOf(
114 + TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"),
115 + ),
116 + ),
117 + )
118 +
119 + engine.syncNow()
120 +
121 + assertNull(db.crmContactDao().getByServerId("c-old"))
122 + }
123 +
124 + @Test
125 + fun syncNow_updatesWatermarkFromServerTime() = runBlocking {
126 + api.pullResult = AilianceApiClient.ApiResult.Ok(
127 + SyncPullResponse(serverTime = "2026-07-22T12:05:00Z"),
128 + )
129 +
130 + val result = engine.syncNow()
131 +
132 + assertTrue(result.success)
133 + assertEquals("2026-07-22T12:05:00Z", result.serverTime)
134 + assertEquals("2026-07-22T12:05:00Z", db.syncMetaDao().get("watermark")?.value)
135 + }
136 +
137 + @Test
138 + fun syncNow_pushesContactCreateOpAndMapsServerId() = runBlocking {
139 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau"))
140 + db.syncOpDao().insert(
141 + SyncOpEntity(
142 + entityType = "contact",
143 + op = "create",
144 + payloadJson = """{"fullName":"Nouveau"}""",
145 + localId = localId,
146 + createdAt = 1L,
147 + ),
148 + )
149 +
150 + engine.syncNow()
151 +
152 + assertEquals(1, api.createContactCalls.size)
153 + assertEquals(0, db.syncOpDao().listAll().size)
154 + assertEquals("srv-generated", db.crmContactDao().getById(localId)?.serverId)
155 + }
156 +
157 + @Test
158 + fun syncNow_pushesCardImageAfterContactCreate() = runBlocking {
159 + val ctx = ApplicationProvider.getApplicationContext<Context>()
160 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
161 + engine = SyncEngine(api, db, images)
162 +
163 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Avec carte"))
164 + val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(1, 2, 3, 4))
165 + db.crmContactDao().update(
166 + db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath),
167 + )
168 + db.syncOpDao().insert(
169 + SyncOpEntity(
170 + entityType = "contact",
171 + op = "create",
172 + payloadJson = """{"prenom":"A","nom":"B"}""",
173 + localId = localId,
174 + createdAt = 1L,
175 + ),
176 + )
177 +
178 + engine.syncNow()
179 +
180 + assertEquals(1, api.createContactCalls.size)
181 + assertEquals(1, api.uploadCarteCalls.size)
182 + assertEquals("srv-generated", api.uploadCarteCalls[0].first)
183 + assertEquals(4, api.uploadCarteCalls[0].second)
184 + assertEquals(0, db.syncOpDao().listAll().size)
185 + }
186 +
187 + @Test
188 + fun syncNow_enqueuesMediaRetryWhenUploadFails() = runBlocking {
189 + val ctx = ApplicationProvider.getApplicationContext<Context>()
190 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
191 + engine = SyncEngine(api, db, images)
192 + api.uploadCarteResult = AilianceApiClient.ApiResult.Err(500, "boom")
193 +
194 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Retry"))
195 + val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(9))
196 + db.crmContactDao().update(
197 + db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath),
198 + )
199 + db.syncOpDao().insert(
200 + SyncOpEntity(
201 + entityType = "contact",
202 + op = "create",
203 + payloadJson = """{"prenom":"A","nom":"B"}""",
204 + localId = localId,
205 + createdAt = 1L,
206 + ),
207 + )
208 +
209 + engine.syncNow()
210 +
211 + val pending = db.syncOpDao().listAll()
212 + assertEquals(1, pending.size)
213 + assertEquals("contact_media", pending[0].entityType)
214 + assertEquals("srv-generated", pending[0].serverId)
215 + }
216 +
217 + @Test
218 + fun syncNow_pullDownloadsMissingCardImage() = runBlocking {
219 + val ctx = ApplicationProvider.getApplicationContext<Context>()
220 + val images = fr.ebii.card2vcf.data.ContactImageStore(ctx)
221 + engine = SyncEngine(api, db, images)
222 + api.downloadCarteResult = AilianceApiClient.ApiResult.Ok(byteArrayOf(7, 8, 9))
223 + api.pullResult = AilianceApiClient.ApiResult.Ok(
224 + SyncPullResponse(
225 + serverTime = "2026-07-22T12:05:00Z",
226 + contacts = listOf(
227 + ContactDto(
228 + id = "c-img",
229 + prenom = "Marie",
230 + nom = "Curie",
231 + creeLe = "2026-07-20T10:00:00Z",
232 + carteVisite = "carte.jpg",
233 + ),
234 + ),
235 + ),
236 + )
237 +
238 + engine.syncNow()
239 +
240 + assertEquals(listOf("c-img"), api.downloadCarteCalls)
241 + val stored = db.crmContactDao().getByServerId("c-img")
242 + assertTrue(!stored?.cardImagePath.isNullOrBlank())
243 + assertTrue(java.io.File(stored!!.cardImagePath!!).isFile)
244 + }
245 +
246 + @Test
247 + fun syncNow_skipsImageUploadWhenNoLocalFile() = runBlocking {
248 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Sans image"))
249 + db.syncOpDao().insert(
250 + SyncOpEntity(
251 + entityType = "contact",
252 + op = "create",
253 + payloadJson = """{"prenom":"A","nom":"B"}""",
254 + localId = localId,
255 + createdAt = 1L,
256 + ),
257 + )
258 +
259 + engine.syncNow()
260 +
261 + assertEquals(0, api.uploadCarteCalls.size)
262 + assertEquals(0, api.uploadPhotoCalls.size)
263 + }
264 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncSchemaDaoTest.kt
+92 -0
@@ -0,0 +1,92 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.TacheEntity
8 +import kotlinx.coroutines.runBlocking
9 +import org.junit.After
10 +import org.junit.Assert.assertEquals
11 +import org.junit.Assert.assertNotNull
12 +import org.junit.Assert.assertNull
13 +import org.junit.Before
14 +import org.junit.Test
15 +import org.junit.runner.RunWith
16 +import org.robolectric.RobolectricTestRunner
17 +import org.robolectric.annotation.Config
18 +
19 +@RunWith(RobolectricTestRunner::class)
20 +@Config(sdk = [31])
21 +class SyncSchemaDaoTest {
22 + private lateinit var db: CrmDatabase
23 + private lateinit var syncOpDao: SyncOpDao
24 + private lateinit var tacheDao: fr.ebii.card2vcf.data.TacheDao
25 +
26 + @Before
27 + fun setUp() {
28 + val ctx = ApplicationProvider.getApplicationContext<Context>()
29 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
30 + .allowMainThreadQueries()
31 + .build()
32 + syncOpDao = db.syncOpDao()
33 + tacheDao = db.tacheDao()
34 + }
35 +
36 + @After
37 + fun tearDown() = db.close()
38 +
39 + @Test
40 + fun syncOpInsertAndList() = runBlocking {
41 + val id = syncOpDao.insert(
42 + SyncOpEntity(
43 + entityType = "contact",
44 + op = "create",
45 + payloadJson = """{"fullName":"Ada"}""",
46 + localId = 42L,
47 + createdAt = 1000L,
48 + ),
49 + )
50 + val ops = syncOpDao.listAll()
51 + assertEquals(1, ops.size)
52 + assertEquals(id, ops[0].id)
53 + assertEquals("contact", ops[0].entityType)
54 + assertEquals("create", ops[0].op)
55 + assertEquals(42L, ops[0].localId)
56 + assertNotNull(syncOpDao.getById(id))
57 + syncOpDao.deleteById(id)
58 + assertNull(syncOpDao.getById(id))
59 + assertEquals(0, syncOpDao.listAll().size)
60 + }
61 +
62 + @Test
63 + fun tacheUpsertAndGetByServerId() = runBlocking {
64 + val localId = tacheDao.upsert(
65 + TacheEntity(
66 + serverId = "tache-1",
67 + projetServerId = "projet-1",
68 + titre = "Préparer démo",
69 + colonneId = "col-a",
70 + ordre = 2,
71 + assigneA = "alice",
72 + auteur = "bob",
73 + createdAt = 500L,
74 + updatedAt = 600L,
75 + ),
76 + )
77 + val found = tacheDao.getByServerId("tache-1")
78 + assertNotNull(found)
79 + assertEquals(localId, found!!.localId)
80 + assertEquals("Préparer démo", found.titre)
81 + assertEquals("projet-1", found.projetServerId)
82 + assertEquals(2, found.ordre)
83 + assertEquals("alice", found.assigneA)
84 +
85 + tacheDao.upsert(found.copy(titre = "Démo prête", updatedAt = 700L))
86 + assertEquals("Démo prête", tacheDao.getByServerId("tache-1")!!.titre)
87 +
88 + assertEquals(1, tacheDao.listByProjetServerId("projet-1").size)
89 + tacheDao.deleteByServerId("tache-1")
90 + assertNull(tacheDao.getByServerId("tache-1"))
91 + }
92 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+347 -0
@@ -0,0 +1,347 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
6 +import fr.ebii.card2vcf.sync.AilianceApi
7 +import fr.ebii.card2vcf.sync.AilianceApiClient
8 +import fr.ebii.card2vcf.sync.AuthCleResponse
9 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
10 +import fr.ebii.card2vcf.sync.FakeCalendarBridge
11 +import fr.ebii.card2vcf.sync.RessourceItemDto
12 +import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
13 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
14 +import kotlinx.coroutines.Dispatchers
15 +import kotlinx.coroutines.ExperimentalCoroutinesApi
16 +import kotlinx.coroutines.test.StandardTestDispatcher
17 +import kotlinx.coroutines.test.advanceUntilIdle
18 +import kotlinx.coroutines.test.resetMain
19 +import kotlinx.coroutines.test.runTest
20 +import kotlinx.coroutines.test.setMain
21 +import org.junit.After
22 +import org.junit.Assert.assertEquals
23 +import org.junit.Assert.assertFalse
24 +import org.junit.Assert.assertNull
25 +import org.junit.Assert.assertTrue
26 +import org.junit.Before
27 +import org.junit.Test
28 +import org.junit.runner.RunWith
29 +import org.robolectric.RobolectricTestRunner
30 +import org.robolectric.annotation.Config
31 +
32 +/** Fake [AilianceApi] : `authCle` (login) et `getRessourcesCatalogue` (section calendriers) importent pour [SettingsViewModel]. */
33 +private class FakeAilianceApi(
34 + private val authResult: AilianceApiClient.ApiResult<AuthCleResponse> =
35 + AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"),
36 + private val catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> =
37 + AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
38 +) : AilianceApi {
39 + override fun authCle(nom: String, motDePasse: String) = authResult
40 + override fun syncStatus(sinceIso: String, ressourcesQuery: String?) = error("not used")
41 + override fun syncPull(sinceIso: String, ressourcesQuery: String?) = error("not used")
42 + override fun createContact(jsonBody: String) = error("not used")
43 + override fun updateContact(id: String, jsonBody: String) = error("not used")
44 + override fun deleteContact(id: String) = error("not used")
45 + override fun createEntreprise(jsonBody: String) = error("not used")
46 + override fun updateEntreprise(id: String, jsonBody: String) = error("not used")
47 + override fun deleteEntreprise(id: String) = error("not used")
48 + override fun createProjet(jsonBody: String) = error("not used")
49 + override fun updateProjet(id: String, jsonBody: String) = error("not used")
50 + override fun deleteProjet(id: String) = error("not used")
51 + override fun createTache(projetId: String, jsonBody: String) = error("not used")
52 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
53 + override fun deleteTache(projetId: String, tacheId: String) = error("not used")
54 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
55 + override fun createInteraction(contactId: String, jsonBody: String) = error("not used")
56 + override fun listRdv() = error("not used")
57 + override fun createRdv(jsonBody: String) = error("not used")
58 + override fun getRdv(id: String) = error("not used")
59 + override fun updateRdv(id: String, jsonBody: String) = error("not used")
60 + override fun deleteRdv(id: String) = error("not used")
61 + override fun getRessourcesCatalogue() = catalogueResult
62 + override fun listReservations(genre: String, resourceId: String) = error("not used")
63 + override fun createReservation(genre: String, resourceId: String, jsonBody: String) = error("not used")
64 + override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) = error("not used")
65 + override fun deleteReservation(genre: String, resourceId: String, reservationId: String) = error("not used")
66 + override fun listIndisponibilites(genre: String, resourceId: String) = error("not used")
67 + override fun uploadContactCarte(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
68 + override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
69 + override fun downloadContactCarte(id: String) = error("not used")
70 + override fun downloadContactPhoto(id: String) = error("not used")
71 +}
72 +
73 +@OptIn(ExperimentalCoroutinesApi::class)
74 +@RunWith(RobolectricTestRunner::class)
75 +@Config(sdk = [31])
76 +class SettingsViewModelTest {
77 + private val testDispatcher = StandardTestDispatcher()
78 + private lateinit var store: SyncCredentialsStore
79 + private lateinit var calendarBindingsStore: CalendarBindingsStore
80 + private lateinit var calendarBridge: FakeCalendarBridge
81 +
82 + @Before
83 + fun setUp() {
84 + Dispatchers.setMain(testDispatcher)
85 + val ctx = ApplicationProvider.getApplicationContext<Context>()
86 + val prefs = ctx.getSharedPreferences("settings_vm_test", Context.MODE_PRIVATE)
87 + prefs.edit().clear().apply()
88 + store = SyncCredentialsStore(prefs)
89 + val bindingsPrefs = ctx.getSharedPreferences("settings_vm_test_bindings", Context.MODE_PRIVATE)
90 + bindingsPrefs.edit().clear().apply()
91 + calendarBindingsStore = CalendarBindingsStore(bindingsPrefs)
92 + calendarBridge = FakeCalendarBridge()
93 + }
94 +
95 + @After
96 + fun tearDown() {
97 + Dispatchers.resetMain()
98 + }
99 +
100 + private fun newViewModel(
101 + catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
102 + authResult: AilianceApiClient.ApiResult<AuthCleResponse> = AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"),
103 + ) = SettingsViewModel(
104 + credentialsStore = store,
105 + calendarBindingsStore = calendarBindingsStore,
106 + calendarBridge = calendarBridge,
107 + apiFactory = { FakeAilianceApi(authResult = authResult) },
108 + authenticatedApiFactory = { _, _ -> FakeAilianceApi(catalogueResult = catalogueResult) },
109 + ioDispatcher = testDispatcher,
110 + )
111 +
112 + @Test
113 + fun obtainKeyThenRetype_savesCredentials() = runTest(testDispatcher) {
114 + val authResponse = AuthCleResponse(
115 + nom = "alice",
116 + cleChiffree = FIXTURE_BLOB_B64,
117 + sel = FIXTURE_SEL_B64,
118 + kdf = "argon2id",
119 + cipher = "xchacha20poly1305",
120 + )
121 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(authResponse))
122 +
123 + vm.setBaseUrl("https://api.example.com")
124 + vm.setUserName("alice")
125 + vm.setPassword(FIXTURE_MDP)
126 + vm.obtainKey()
127 + advanceUntilIdle()
128 +
129 + val afterAuth = vm.state.value
130 + assertTrue(afterAuth is SettingsUiState.LoggedOut)
131 + assertEquals(authResponse, (afterAuth as SettingsUiState.LoggedOut).pendingAuth)
132 + assertEquals("", afterAuth.password)
133 +
134 + vm.setRetypePassword(FIXTURE_MDP)
135 + vm.confirmRetype()
136 +
137 + val loggedIn = vm.state.value
138 + assertTrue(loggedIn is SettingsUiState.LoggedIn)
139 + assertEquals("alice", (loggedIn as SettingsUiState.LoggedIn).userName)
140 + assertEquals(FIXTURE_CLE, store.apiKey)
141 + assertEquals("https://api.example.com", store.baseUrl)
142 + }
143 +
144 + @Test
145 + fun obtainKey_invalidCredentials_showsInkError() = runTest(testDispatcher) {
146 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Err(401, "unauthorized"))
147 +
148 + vm.setBaseUrl("https://api.example.com")
149 + vm.setUserName("alice")
150 + vm.setPassword("bad-password")
151 + vm.obtainKey()
152 + advanceUntilIdle()
153 +
154 + val state = vm.state.value
155 + assertTrue(state is SettingsUiState.LoggedOut)
156 + assertEquals("Identifiants invalides", (state as SettingsUiState.LoggedOut).error)
157 + }
158 +
159 + @Test
160 + fun obtainKey_httpUrl_rejectedUnlessCleartextAllowed() = runTest(testDispatcher) {
161 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(AuthCleResponse("a", "b", "c", "argon2id", "x")))
162 +
163 + vm.setBaseUrl("http://192.168.1.10:3000")
164 + vm.setUserName("alice")
165 + vm.setPassword("secret")
166 + vm.obtainKey()
167 + advanceUntilIdle()
168 +
169 + val rejected = vm.state.value as SettingsUiState.LoggedOut
170 + assertTrue(rejected.error!!.contains("https://"))
171 + assertFalse(rejected.loading)
172 + assertNull(rejected.pendingAuth)
173 +
174 + vm.setAllowCleartextHttp(true)
175 + vm.obtainKey()
176 + advanceUntilIdle()
177 +
178 + val allowed = vm.state.value as SettingsUiState.LoggedOut
179 + assertNull(allowed.error)
180 + assertEquals("a", allowed.pendingAuth?.nom)
181 + assertTrue(store.allowCleartextHttp)
182 + }
183 +
184 + @Test
185 + fun confirmRetype_wrongPassword_keepsRetypeError() = runTest(testDispatcher) {
186 + val authResponse = AuthCleResponse(
187 + nom = "alice",
188 + cleChiffree = FIXTURE_BLOB_B64,
189 + sel = FIXTURE_SEL_B64,
190 + kdf = "argon2id",
191 + cipher = "xchacha20poly1305",
192 + )
193 + val vm = newViewModel(authResult = AilianceApiClient.ApiResult.Ok(authResponse))
194 + vm.setBaseUrl("https://api.example.com")
195 + vm.setUserName("alice")
196 + vm.setPassword(FIXTURE_MDP)
197 + vm.obtainKey()
198 + advanceUntilIdle()
199 +
200 + vm.setRetypePassword("wrong-password")
201 + vm.confirmRetype()
202 +
203 + val state = vm.state.value
204 + assertTrue(state is SettingsUiState.LoggedOut)
205 + assertEquals("Mot de passe incorrect", (state as SettingsUiState.LoggedOut).retypeError)
206 + assertTrue(!store.isConfigured())
207 + }
208 +
209 + // ---- Task M6 : Calendriers ----
210 +
211 + private fun loggedInViewModel(
212 + catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
213 + ): SettingsViewModel {
214 + store.save(baseUrl = "https://api.example.com", apiKey = "sk_fixture", userName = "alice")
215 + return newViewModel(catalogueResult = catalogueResult)
216 + }
217 +
218 + @Test
219 + fun onCalendarPermissionChanged_granted_loadsRessourcesCatalogue() = runTest(testDispatcher) {
220 + val catalogue = RessourcesCatalogueDto(
221 + salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = true)),
222 + )
223 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
224 +
225 + vm.onCalendarPermissionChanged(true)
226 + advanceUntilIdle()
227 +
228 + val state = vm.state.value as SettingsUiState.LoggedIn
229 + assertTrue(state.calendarPermissionGranted)
230 + assertEquals(1, state.ressources.size)
231 + assertEquals("s1", state.ressources.single().serverResourceId)
232 + assertFalse(state.catalogueLoading)
233 + }
234 +
235 + @Test
236 + fun onCalendarPermissionChanged_denied_doesNotFetchCatalogue() = runTest(testDispatcher) {
237 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Err(500, "should not be called"))
238 +
239 + vm.onCalendarPermissionChanged(false)
240 + advanceUntilIdle()
241 +
242 + val state = vm.state.value as SettingsUiState.LoggedIn
243 + assertFalse(state.calendarPermissionGranted)
244 + assertTrue(state.ressources.isEmpty())
245 + assertNull(state.catalogueError)
246 + }
247 +
248 + @Test
249 + fun setMesRdvEnabled_true_createsCalendarAndUpsertsBinding() = runTest(testDispatcher) {
250 + val vm = loggedInViewModel()
251 +
252 + vm.setMesRdvEnabled(true)
253 + advanceUntilIdle()
254 +
255 + val state = vm.state.value as SettingsUiState.LoggedIn
256 + assertTrue(state.mesRdvLinked)
257 + val binding = calendarBindingsStore.list().single()
258 + assertEquals(AgendaSyncCoordinator.KIND_RDV, binding.kind)
259 + assertEquals("PicLead — Mes RDV", binding.displayName)
260 + assertTrue(binding.androidCalendarId != null)
261 + }
262 +
263 + @Test
264 + fun setMesRdvEnabled_false_setsPendingRemovalWithoutClearingBindingYet() = runTest(testDispatcher) {
265 + val vm = loggedInViewModel()
266 + vm.setMesRdvEnabled(true)
267 + advanceUntilIdle()
268 +
269 + vm.setMesRdvEnabled(false)
270 +
271 + val state = vm.state.value as SettingsUiState.LoggedIn
272 + assertEquals(AgendaSyncCoordinator.KIND_RDV, state.pendingRemoval?.kind)
273 + assertTrue(state.mesRdvLinked) // pas encore retiré : attend confirmation
274 + assertEquals(1, calendarBindingsStore.list().size)
275 + }
276 +
277 + @Test
278 + fun confirmRemoveBinding_removesMesRdvBinding() = runTest(testDispatcher) {
279 + val vm = loggedInViewModel()
280 + vm.setMesRdvEnabled(true)
281 + advanceUntilIdle()
282 + vm.setMesRdvEnabled(false)
283 +
284 + vm.confirmRemoveBinding()
285 +
286 + val state = vm.state.value as SettingsUiState.LoggedIn
287 + assertFalse(state.mesRdvLinked)
288 + assertNull(state.pendingRemoval)
289 + assertTrue(calendarBindingsStore.list().isEmpty())
290 + }
291 +
292 + @Test
293 + fun dismissRemoveBinding_keepsBindingAndClearsDialog() = runTest(testDispatcher) {
294 + val vm = loggedInViewModel()
295 + vm.setMesRdvEnabled(true)
296 + advanceUntilIdle()
297 + vm.setMesRdvEnabled(false)
298 +
299 + vm.dismissRemoveBinding()
300 +
301 + val state = vm.state.value as SettingsUiState.LoggedIn
302 + assertTrue(state.mesRdvLinked)
303 + assertNull(state.pendingRemoval)
304 + assertEquals(1, calendarBindingsStore.list().size)
305 + }
306 +
307 + @Test
308 + fun setRessourceEnabled_true_createsCalendarNamedFromCatalogue() = runTest(testDispatcher) {
309 + val catalogue = RessourcesCatalogueDto(salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = true)))
310 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
311 + vm.onCalendarPermissionChanged(true)
312 + advanceUntilIdle()
313 +
314 + vm.setRessourceEnabled(kind = "salle", serverResourceId = "s1", enabled = true)
315 + advanceUntilIdle()
316 +
317 + val state = vm.state.value as SettingsUiState.LoggedIn
318 + assertTrue(state.ressources.single().linked)
319 + val binding = calendarBindingsStore.list().single()
320 + assertEquals("salle", binding.kind)
321 + assertEquals("s1", binding.serverResourceId)
322 + assertEquals("PicLead — Salle A", binding.displayName)
323 + }
324 +
325 + @Test
326 + fun setRessourceEnabled_onInactiveRessource_isNoOp() = runTest(testDispatcher) {
327 + val catalogue = RessourcesCatalogueDto(salles = listOf(RessourceItemDto(id = "s1", nom = "A", actif = false)))
328 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
329 + vm.onCalendarPermissionChanged(true)
330 + advanceUntilIdle()
331 +
332 + vm.setRessourceEnabled(kind = "salle", serverResourceId = "s1", enabled = true)
333 + advanceUntilIdle()
334 +
335 + val state = vm.state.value as SettingsUiState.LoggedIn
336 + assertFalse(state.ressources.single().linked)
337 + assertTrue(calendarBindingsStore.list().isEmpty())
338 + }
339 +
340 + private companion object {
341 + const val FIXTURE_MDP = "card2vcf-fixture-mdp"
342 + const val FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
343 + const val FIXTURE_SEL_B64 = "Fc+EFikMN7Cb8YoNZB+SZw=="
344 + const val FIXTURE_BLOB_B64 =
345 + "uW9uB9fP7m5ng6PoH9PGCXBKdPoVj9SceWy9GWLjg4x3Zk7MirbHhSCBVyLzoL2tK4qDir7UCOn503+s9rUFvIT/Eg5NqaU="
346 + }
347 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+179 -0
@@ -0,0 +1,179 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.RdvEntity
8 +import fr.ebii.card2vcf.data.ReservationEntity
9 +import fr.ebii.card2vcf.sync.AilianceApiClient
10 +import fr.ebii.card2vcf.sync.CalendarBindingsStore
11 +import fr.ebii.card2vcf.sync.FakeAilianceApi
12 +import fr.ebii.card2vcf.sync.FakeCalendarBridge
13 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
14 +import fr.ebii.card2vcf.sync.SyncEngine
15 +import fr.ebii.card2vcf.sync.SyncOpEntity
16 +import kotlinx.coroutines.Dispatchers
17 +import kotlinx.coroutines.ExperimentalCoroutinesApi
18 +import kotlinx.coroutines.runBlocking
19 +import kotlinx.coroutines.test.resetMain
20 +import kotlinx.coroutines.test.setMain
21 +import org.junit.After
22 +import org.junit.Assert.assertEquals
23 +import org.junit.Assert.assertNull
24 +import org.junit.Assert.assertTrue
25 +import org.junit.Before
26 +import org.junit.Test
27 +import org.junit.runner.RunWith
28 +import org.robolectric.RobolectricTestRunner
29 +import org.robolectric.annotation.Config
30 +
31 +/**
32 + * Task M7 : bandeau « calendrier local en avance » + dialog conflit réservation (409).
33 + *
34 + * [SyncEngine] fixe `Dispatchers.IO` en interne (voir `SyncEngine.kt`), incompatible avec le temps
35 + * virtuel de `runTest`/`StandardTestDispatcher`. On utilise donc `Dispatchers.Unconfined` comme
36 + * dispatcher `Main` et on attend les effets par scrutation ([awaitUntil]) plutôt que par
37 + * `advanceUntilIdle`, comme le fait déjà `AgendaSyncEngineTest` (`runBlocking` direct sur l'engine).
38 + */
39 +@OptIn(ExperimentalCoroutinesApi::class)
40 +@RunWith(RobolectricTestRunner::class)
41 +@Config(sdk = [31])
42 +class SyncChromeViewModelTest {
43 + private lateinit var db: CrmDatabase
44 + private lateinit var api: FakeAilianceApi
45 + private lateinit var bridge: FakeCalendarBridge
46 + private lateinit var bindingsStore: CalendarBindingsStore
47 + private lateinit var credentialsStore: SyncCredentialsStore
48 +
49 + @Before
50 + fun setUp() {
51 + Dispatchers.setMain(Dispatchers.Unconfined)
52 + val ctx = ApplicationProvider.getApplicationContext<Context>()
53 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java).allowMainThreadQueries().build()
54 + api = FakeAilianceApi()
55 + bridge = FakeCalendarBridge()
56 + val bindingsPrefs = ctx.getSharedPreferences("sync_chrome_vm_test_bindings", Context.MODE_PRIVATE)
57 + bindingsPrefs.edit().clear().apply()
58 + bindingsStore = CalendarBindingsStore(bindingsPrefs)
59 + val credsPrefs = ctx.getSharedPreferences("sync_chrome_vm_test_creds", Context.MODE_PRIVATE)
60 + credsPrefs.edit().clear().apply()
61 + credentialsStore = SyncCredentialsStore(credsPrefs)
62 + credentialsStore.save(baseUrl = "https://api.example.com", apiKey = "sk_fixture", userName = "alice")
63 + }
64 +
65 + @After
66 + fun tearDown() {
67 + Dispatchers.resetMain()
68 + db.close()
69 + }
70 +
71 + private fun newViewModel() = SyncChromeViewModel(
72 + credentialsStore = credentialsStore,
73 + calendarBindingsStore = bindingsStore,
74 + calendarBridge = bridge,
75 + engineFactory = { SyncEngine(api, db) },
76 + )
77 +
78 + private fun awaitUntil(timeoutMs: Long = 3000, condition: () -> Boolean) {
79 + val deadline = System.currentTimeMillis() + timeoutMs
80 + while (!condition() && System.currentTimeMillis() < deadline) {
81 + Thread.sleep(5)
82 + }
83 + assertTrue("Condition non atteinte sous ${timeoutMs}ms", condition())
84 + }
85 +
86 + @Test
87 + fun refreshStatus_populatesLocalCalendarAheadFromDirtyRdv() = runBlocking {
88 + db.rdvDao().upsert(RdvEntity(titre = "Dirty", dirtyLocal = true))
89 + val vm = newViewModel()
90 +
91 + vm.refreshStatus()
92 +
93 + awaitUntil { vm.localCalendarAhead.value == 1 }
94 + }
95 +
96 + @Test
97 + fun refreshStatus_passesRessourcesQueryFromBindings() {
98 + bindingsStore.upsert(
99 + fr.ebii.card2vcf.sync.CalendarBinding(
100 + kind = "salle",
101 + serverResourceId = "s1",
102 + displayName = "Card2vcf — Salle A",
103 + androidCalendarId = 1L,
104 + ),
105 + )
106 + val vm = newViewModel()
107 +
108 + vm.refreshStatus()
109 +
110 + awaitUntil { api.statusQueries.isNotEmpty() }
111 + assertEquals("salle:s1", api.statusQueries.last())
112 + }
113 +
114 + @Test
115 + fun syncNow_success_refreshesLocalCalendarAheadToZeroWhenClean() {
116 + val vm = newViewModel()
117 +
118 + vm.syncNow()
119 +
120 + awaitUntil { !vm.syncing.value }
121 + assertEquals(0, vm.localCalendarAhead.value)
122 + assertNull(vm.conflictPending.value)
123 + }
124 +
125 + @Test
126 + fun syncNow_withReservationConflict_setsConflictPending() = runBlocking {
127 + val localId = db.reservationDao().upsert(
128 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
129 + )
130 + db.syncOpDao().insert(
131 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
132 + )
133 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
134 + val vm = newViewModel()
135 +
136 + vm.syncNow()
137 +
138 + awaitUntil { vm.conflictPending.value != null }
139 + assertEquals(localId, vm.conflictPending.value?.localReservationId)
140 + }
141 +
142 + @Test
143 + fun confirmConflictCancellation_removesReservationAndClearsDialog() = runBlocking {
144 + val localId = db.reservationDao().upsert(
145 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
146 + )
147 + db.syncOpDao().insert(
148 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
149 + )
150 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
151 + val vm = newViewModel()
152 + vm.syncNow()
153 + awaitUntil { vm.conflictPending.value != null }
154 +
155 + vm.confirmConflictCancellation()
156 + awaitUntil { vm.conflictPending.value == null }
157 +
158 + assertNull(db.reservationDao().getByLocalId(localId))
159 + }
160 +
161 + @Test
162 + fun dismissConflict_clearsDialogButKeepsConflictPendingEntity() = runBlocking {
163 + val localId = db.reservationDao().upsert(
164 + ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true),
165 + )
166 + db.syncOpDao().insert(
167 + SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L),
168 + )
169 + api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement")
170 + val vm = newViewModel()
171 + vm.syncNow()
172 + awaitUntil { vm.conflictPending.value != null }
173 +
174 + vm.dismissConflict()
175 +
176 + assertNull(vm.conflictPending.value)
177 + assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
178 + }
179 +}
A android/app/src/test/resources/cards/dimo_genoux.jpg
+0 -0
A android/app/src/test/resources/cards/dimo_genoux_oriented.jpg
+0 -0
A android/app/src/test/resources/vcf/sample3.vcf
+14 -0
@@ -0,0 +1,14 @@
1 +BEGIN:VCARD
2 +VERSION:3.0
3 +FN:Alice Martin
4 +N:Martin;Alice;;;
5 +ORG:Acme
6 +EMAIL;TYPE=INTERNET:alice@acme.test
7 +TEL;TYPE=CELL:+33601020304
8 +NOTE:salon Lyon
9 +END:VCARD
10 +BEGIN:VCARD
11 +VERSION:3.0
12 +FN:Bob
13 +EMAIL:bob@unique.test
14 +END:VCARD
A android/app/src/test/resources/vcf/sample4.vcf
+7 -0
@@ -0,0 +1,7 @@
1 +BEGIN:VCARD
2 +VERSION:4.0
3 +FN:Alice V4
4 +N:V4;Alice;;;
5 +EMAIL;TYPE=work:alice@acme.test
6 +TEL;TYPE=cell:+33601020304
7 +END:VCARD
A android/build.gradle.kts
+7 -0
@@ -0,0 +1,7 @@
1 +plugins {
2 + id("com.android.application") version "8.6.0" apply false
3 + id("org.jetbrains.kotlin.android") version "2.0.20" apply false
4 + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
5 + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" apply false
6 + id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false
7 +}
A android/gradle.properties
+4 -0
@@ -0,0 +1,4 @@
1 +org.gradle.jvmargs=-Xmx2048m
2 +android.useAndroidX=true
3 +kotlin.code.style=official
4 +android.nonTransitiveRClass=true
A android/gradle/wrapper/gradle-wrapper.jar
+0 -0
A android/gradle/wrapper/gradle-wrapper.properties
+9 -0
@@ -0,0 +1,9 @@
1 +distributionBase=GRADLE_USER_HOME
2 +distributionPath=wrapper/dists
3 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4 +networkTimeout=10000
5 +retries=0
6 +retryBackOffMs=500
7 +validateDistributionUrl=true
8 +zipStoreBase=GRADLE_USER_HOME
9 +zipStorePath=wrapper/dists
A android/gradlew
+248 -0
@@ -0,0 +1,248 @@
1 +#!/bin/sh
2 +
3 +#
4 +# Copyright © 2015 the original authors.
5 +#
6 +# Licensed under the Apache License, Version 2.0 (the "License");
7 +# you may not use this file except in compliance with the License.
8 +# You may obtain a copy of the License at
9 +#
10 +# https://www.apache.org/licenses/LICENSE-2.0
11 +#
12 +# Unless required by applicable law or agreed to in writing, software
13 +# distributed under the License is distributed on an "AS IS" BASIS,
14 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 +# See the License for the specific language governing permissions and
16 +# limitations under the License.
17 +#
18 +# SPDX-License-Identifier: Apache-2.0
19 +#
20 +
21 +##############################################################################
22 +#
23 +# gradlew start up script for POSIX generated by Gradle.
24 +#
25 +# Important for running:
26 +#
27 +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 +# noncompliant, but you have some other compliant shell such as ksh or
29 +# bash, then to run this script, type that shell name before the whole
30 +# command line, like:
31 +#
32 +# ksh gradlew
33 +#
34 +# Busybox and similar reduced shells will NOT work, because this script
35 +# requires all of these POSIX shell features:
36 +# * functions;
37 +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 +# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 +# * compound commands having a testable exit status, especially «case»;
40 +# * various built-in commands including «command», «set», and «ulimit».
41 +#
42 +# Important for patching:
43 +#
44 +# (2) This script targets any POSIX shell, so it avoids extensions provided
45 +# by Bash, Ksh, etc; in particular arrays are avoided.
46 +#
47 +# The "traditional" practice of packing multiple parameters into a
48 +# space-separated string is a well documented source of bugs and security
49 +# problems, so this is (mostly) avoided, by progressively accumulating
50 +# options in "$@", and eventually passing that to Java.
51 +#
52 +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 +# see the in-line comments for details.
55 +#
56 +# There are tweaks for specific operating systems such as AIX, CygWin,
57 +# Darwin, MinGW, and NonStop.
58 +#
59 +# (3) This script is generated from the Groovy template
60 +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 +# within the Gradle project.
62 +#
63 +# You can find Gradle at https://github.com/gradle/gradle/.
64 +#
65 +##############################################################################
66 +
67 +# Attempt to set APP_HOME
68 +
69 +# Resolve links: $0 may be a link
70 +app_path=$0
71 +
72 +# Need this for daisy-chained symlinks.
73 +while
74 + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 + [ -h "$app_path" ]
76 +do
77 + ls=$( ls -ld "$app_path" )
78 + link=${ls#*' -> '}
79 + case $link in #(
80 + /*) app_path=$link ;; #(
81 + *) app_path=$APP_HOME$link ;;
82 + esac
83 +done
84 +
85 +# This is normally unused
86 +# shellcheck disable=SC2034
87 +APP_BASE_NAME=${0##*/}
88 +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 +
91 +# Use the maximum available, or set MAX_FD != -1 to use that value.
92 +MAX_FD=maximum
93 +
94 +warn () {
95 + echo "$*"
96 +} >&2
97 +
98 +die () {
99 + echo
100 + echo "$*"
101 + echo
102 + exit 1
103 +} >&2
104 +
105 +# OS specific support (must be 'true' or 'false').
106 +cygwin=false
107 +msys=false
108 +darwin=false
109 +nonstop=false
110 +case "$( uname )" in #(
111 + CYGWIN* ) cygwin=true ;; #(
112 + Darwin* ) darwin=true ;; #(
113 + MSYS* | MINGW* ) msys=true ;; #(
114 + NONSTOP* ) nonstop=true ;;
115 +esac
116 +
117 +
118 +
119 +# Determine the Java command to use to start the JVM.
120 +if [ -n "$JAVA_HOME" ] ; then
121 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
122 + # IBM's JDK on AIX uses strange locations for the executables
123 + JAVACMD=$JAVA_HOME/jre/sh/java
124 + else
125 + JAVACMD=$JAVA_HOME/bin/java
126 + fi
127 + if [ ! -x "$JAVACMD" ] ; then
128 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
129 +
130 +Please set the JAVA_HOME variable in your environment to match the
131 +location of your Java installation."
132 + fi
133 +else
134 + JAVACMD=java
135 + if ! command -v java >/dev/null 2>&1
136 + then
137 + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
138 +
139 +Please set the JAVA_HOME variable in your environment to match the
140 +location of your Java installation."
141 + fi
142 +fi
143 +
144 +# Increase the maximum file descriptors if we can.
145 +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
146 + case $MAX_FD in #(
147 + max*)
148 + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
149 + # shellcheck disable=SC2039,SC3045
150 + MAX_FD=$( ulimit -H -n ) ||
151 + warn "Could not query maximum file descriptor limit"
152 + esac
153 + case $MAX_FD in #(
154 + '' | soft) :;; #(
155 + *)
156 + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
157 + # shellcheck disable=SC2039,SC3045
158 + ulimit -n "$MAX_FD" ||
159 + warn "Could not set maximum file descriptor limit to $MAX_FD"
160 + esac
161 +fi
162 +
163 +# Collect all arguments for the java command, stacking in reverse order:
164 +# * args from the command line
165 +# * the main class name
166 +# * -classpath
167 +# * -D...appname settings
168 +# * --module-path (only if needed)
169 +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
170 +
171 +# For Cygwin or MSYS, switch paths to Windows format before running java
172 +if "$cygwin" || "$msys" ; then
173 + APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
174 +
175 + JAVACMD=$( cygpath --unix "$JAVACMD" )
176 +
177 + # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 + for arg do
179 + if
180 + case $arg in #(
181 + -*) false ;; # don't mess with options #(
182 + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 + [ -e "$t" ] ;; #(
184 + *) false ;;
185 + esac
186 + then
187 + arg=$( cygpath --path --ignore --mixed "$arg" )
188 + fi
189 + # Roll the args list around exactly as many times as the number of
190 + # args, so each arg winds up back in the position where it started, but
191 + # possibly modified.
192 + #
193 + # NB: a `for` loop captures its iteration list before it begins, so
194 + # changing the positional parameters here affects neither the number of
195 + # iterations, nor the values presented in `arg`.
196 + shift # remove old arg
197 + set -- "$@" "$arg" # push replacement arg
198 + done
199 +fi
200 +
201 +
202 +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 +
205 +# Collect all arguments for the java command:
206 +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 +# and any embedded shellness will be escaped.
208 +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 +# treated as '${Hostname}' itself on the command line.
210 +
211 +set -- \
212 + "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
214 + "$@"
215 +
216 +# Stop when "xargs" is not available.
217 +if ! command -v xargs >/dev/null 2>&1
218 +then
219 + die "xargs is not available"
220 +fi
221 +
222 +# Use "xargs" to parse quoted args.
223 +#
224 +# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
225 +#
226 +# In Bash we could simply go:
227 +#
228 +# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
229 +# set -- "${ARGS[@]}" "$@"
230 +#
231 +# but POSIX shell has neither arrays nor command substitution, so instead we
232 +# post-process each arg (as a line of input to sed) to backslash-escape any
233 +# character that might be a shell metacharacter, then use eval to reverse
234 +# that process (while maintaining the separation between arguments), and wrap
235 +# the whole thing up as a single "set" statement.
236 +#
237 +# This will of course break if any of these variables contains a newline or
238 +# an unmatched quote.
239 +#
240 +
241 +eval "set -- $(
242 + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
243 + xargs -n1 |
244 + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
245 + tr '\n' ' '
246 + )" '"$@"'
247 +
248 +exec "$JAVACMD" "$@"
A android/gradlew.bat
+82 -0
@@ -0,0 +1,82 @@
1 +@rem
2 +@rem Copyright 2015 the original author or authors.
3 +@rem
4 +@rem Licensed under the Apache License, Version 2.0 (the "License");
5 +@rem you may not use this file except in compliance with the License.
6 +@rem You may obtain a copy of the License at
7 +@rem
8 +@rem https://www.apache.org/licenses/LICENSE-2.0
9 +@rem
10 +@rem Unless required by applicable law or agreed to in writing, software
11 +@rem distributed under the License is distributed on an "AS IS" BASIS,
12 +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +@rem See the License for the specific language governing permissions and
14 +@rem limitations under the License.
15 +@rem
16 +@rem SPDX-License-Identifier: Apache-2.0
17 +@rem
18 +
19 +@if "%DEBUG%"=="" @echo off
20 +@rem ##########################################################################
21 +@rem
22 +@rem gradlew startup script for Windows
23 +@rem
24 +@rem ##########################################################################
25 +
26 +@rem Set local scope for the variables, and ensure extensions are enabled
27 +setlocal EnableExtensions
28 +
29 +set DIRNAME=%~dp0
30 +if "%DIRNAME%"=="" set DIRNAME=.
31 +@rem This is normally unused
32 +set APP_BASE_NAME=%~n0
33 +set APP_HOME=%DIRNAME%
34 +
35 +@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 +
38 +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 +
41 +@rem Find java.exe
42 +if defined JAVA_HOME goto findJavaFromJavaHome
43 +
44 +set JAVA_EXE=java.exe
45 +%JAVA_EXE% -version >NUL 2>&1
46 +if %ERRORLEVEL% equ 0 goto execute
47 +
48 +echo. 1>&2
49 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 +echo. 1>&2
51 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 +echo location of your Java installation. 1>&2
53 +
54 +"%COMSPEC%" /c exit 1
55 +
56 +:findJavaFromJavaHome
57 +set JAVA_HOME=%JAVA_HOME:"=%
58 +set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 +
60 +if exist "%JAVA_EXE%" goto execute
61 +
62 +echo. 1>&2
63 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 +echo. 1>&2
65 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 +echo location of your Java installation. 1>&2
67 +
68 +"%COMSPEC%" /c exit 1
69 +
70 +:execute
71 +@rem Setup the command line
72 +
73 +
74 +
75 +@rem Execute gradlew
76 +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
77 +@rem which allows us to clear the local environment before executing the java command
78 +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
79 +
80 +:exitWithErrorLevel
81 +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
82 +"%COMSPEC%" /c exit %ERRORLEVEL%
A android/settings.gradle.kts
+17 -0
@@ -0,0 +1,17 @@
1 +pluginManagement {
2 + repositories {
3 + google()
4 + mavenCentral()
5 + gradlePluginPortal()
6 + }
7 +}
8 +dependencyResolutionManagement {
9 + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 + repositories {
11 + google()
12 + mavenCentral()
13 + maven { url = uri("https://jitpack.io") }
14 + }
15 +}
16 +rootProject.name = "card2vcf"
17 +include(":app")
A design.md
+244 -0
@@ -0,0 +1,244 @@
1 +## Overview
2 +
3 +Wired is the flagship technology-magazine brand under Condé Nast — and the web surface refuses to dress itself as a SaaS marketing site. The page is unmistakably an editorial product: a white canvas, a strict black wordmark in the brand's proprietary `WiredDisplay` (a tall, narrow, high-contrast serif used at 64 px), and stacked story cards that read as a printed magazine grid ported to the screen. There is no atmospheric gradient, no decorative chrome, no chromatic accent — the brand's only colour beyond the black-and-white duet is the small `{colors.link}` (`#057dbc`) used for inline body links inside long-form articles.
4 +
5 +Type carries the entire identity. Three families ladder the system: `WiredDisplay` (the proprietary high-contrast serif) for hero / section headlines; `BreveText` (a humanist serif) for long-form body and bylines; and `Apercu` (a humanist sans) for metadata, captions, eyebrow tags, and buttons. The pairing is editorial-grade: serifs for narrative, sans for navigation and structural labels.
6 +
7 +Buttons are square. The brand uses `{rounded.none}` 0 px corners across the entire UI — newsletter sign-ups, login forms, "Read more" CTAs all render as sharp rectangles. The only circular shape is the `button-icon-circular` used for social-share affordances. There are no soft drop-shadows; the brand uses hairline borders for elevation when needed.
8 +
9 +**Key Characteristics:**
10 +- A strict black-and-white duet with no chromatic accent except the inline link blue `{colors.link}`. The brand reads as a printed magazine.
11 +- Three-face typographic system — `WiredDisplay` serif for display, `BreveText` serif for body, `Apercu` sans for metadata and buttons.
12 +- Square buttons (`{rounded.none}`) — the brand never softens corners on interactive elements.
13 +- A magazine-style story grid: large feature card at top, two-up secondary, then a vertical stack of bylined story rows separated by `{colors.hairline}` 1 px dividers.
14 +- The brand's only signature decorative move is the **masthead band** — a thin black strip with the wordmark centred, no other decoration.
15 +- A near-black `{colors.ink}` (`#000000`) footer band, no graphics, just text columns and the wordmark repeating.
16 +
17 +## Colors
18 +
19 +### Brand & Accent
20 +- **Ink Black** (`{colors.primary}` — `#000000`): The brand's only "accent." Used for wordmark, headlines, CTAs, footer fill. Pure black, never softened.
21 +
22 +### Surface
23 +- **Canvas** (`{colors.canvas}` — `#ffffff`): The default page background.
24 +- **Canvas Soft** (`{colors.canvas-soft}` — `#f5f5f5`): Rare tint used for the comment-section background and search-result row hover states (not in the main page rhythm).
25 +- **Hairline** (`{colors.hairline}` — `#e0e0e0`): 1 px dividers between story rows. The brand's only "line."
26 +
27 +### Text
28 +- **Ink** (`{colors.ink}` — `#000000`): Every headline, every body paragraph in BreveText.
29 +- **Ink Soft** (`{colors.ink-soft}` — `#1a1a1a`): A near-black variant used for caption-strong / footer link emphasis.
30 +- **Body** (`{colors.body}` — `#757575`): Secondary metadata — bylines, timestamps, supporting body lines.
31 +
32 +### Semantic
33 +The brand operates with one inline link colour and no separate error / success / warning palette in its marketing surface. Validation cues on form pages use the ink black + body grey hierarchy.
34 +
35 +- **Link** (`{colors.link}` — `#057dbc`): The inline body-link blue. Used only inside long-form article body copy, never on UI buttons or navigation.
36 +
37 +## Typography
38 +
39 +### Font Family
40 +Three families ladder the system:
41 +1. **WiredDisplay** — the proprietary tall-narrow high-contrast serif used exclusively for display headlines (64 px hero, scaling down to 26 px sub-display). The brand's most-recognisable typographic signature.
42 +2. **BreveText** — the proprietary humanist serif used for long-form body, bylines, and editorial captions. Used at 16 – 19 px line-height 1.45 – 1.50 for comfortable reading density.
43 +3. **Apercu** — a humanist sans used for nav, button labels, category eyebrows, metadata, and captions. Weights 400 / 700.
44 +
45 +Inter is loaded as a fourth fallback face for embedded utility surfaces (the comment section, account pages) but does not appear on the main marketing / article surface.
46 +
47 +### Hierarchy
48 +
49 +| Token | Size | Weight | Line Height | Letter Spacing | Use |
50 +|---|---|---|---|---|---|
51 +| `{typography.display-hero}` | 64px | 400 | 59.5px | -0.5px | Cover-story headline. |
52 +| `{typography.display-lg}` | 48px | 400 | 50.4px | -0.4px | Major section / feature headlines. |
53 +| `{typography.display-md}` | 32px | 400 | 35.2px | -0.3px | Story-card large variant. |
54 +| `{typography.display-sm}` | 26px | 400 | 28px | 0 | Sub-display headings inside long-form articles. |
55 +| `{typography.display-xs}` | 20px | 700 | 24px | -0.28px | Sans (Apercu) display micro-headings for category callouts. |
56 +| `{typography.body-serif-lg}` | 19px | 400 | 27.93px | 0.108px | Lead paragraph of an article (BreveText). |
57 +| `{typography.body-serif-md}` | 16px | 400 | 24px | 0.09px | Default article body (BreveText). |
58 +| `{typography.body-md}` | 17px | 400 | 20px | 0 | Sans body (Apercu) for nav / metadata. |
59 +| `{typography.body-md-strong}` | 17px | 700 | 22px | -0.144px | Bold sans body. |
60 +| `{typography.body-sm}` | 14px | 400 | 18px | 0.4px | Secondary sans body. |
61 +| `{typography.body-sm-strong}` | 14px | 700 | 18px | 0.4px | Bold caption / nav-link. |
62 +| `{typography.byline}` | 12.73px | 700 | 28px | 0.108px | Article byline (BreveText). |
63 +| `{typography.caption}` | 12px | 400 | 16px | 0 | Fine print, image captions. |
64 +| `{typography.button-md}` | 16px | 700 | 20px | 0.3px | Button label. |
65 +
66 +### Principles
67 +- **Serif for narrative, sans for structure.** The serif faces never carry button labels or nav text; the sans face never carries article body.
68 +- **Display weight 400** — the proprietary WiredDisplay reads as elegant by virtue of its thin-tall-narrow design at default weight, not via weight 700+.
69 +- **Bylines use BreveText weight 700 with relaxed line-height 2.2.** The vertical breathing is part of the editorial signature.
70 +
71 +### Note on Font Substitutes
72 +The three proprietary faces have no exact substitutes. Best open-source approximations:
73 +- **WiredDisplay** — *Playfair Display* weight 400 at large display sizes captures the high-contrast didone feel, though wider than the brand's tall-narrow proportions.
74 +- **BreveText** — *Lora* or *Source Serif Pro* at 16 – 19 px.
75 +- **Apercu** — *Inter* or *Manrope* weights 400 / 700.
76 +
77 +## Layout
78 +
79 +### Spacing System
80 +- **Base unit**: 4 px.
81 +- **Tokens**: `{spacing.xxs}` 2 px · `{spacing.xs}` 4 px · `{spacing.sm}` 8 px · `{spacing.md}` 12 px · `{spacing.lg}` 16 px · `{spacing.xl}` 20 px · `{spacing.2xl}` 24 px · `{spacing.3xl}` 32 px · `{spacing.4xl}` 48 px.
82 +- **Section padding**: hero / story grid use `{spacing.4xl}` 48 px top/bottom on desktop.
83 +- **Story row padding**: `{spacing.lg}` 16 px vertical between bylined story rows.
84 +
85 +### Grid & Container
86 +- Marketing content uses a wide container (~1400 px max).
87 +- Cover-story grid: 1 large hero + 2-up secondary stories + vertical stack.
88 +- Story-row stack: full-width single column with hairline dividers.
89 +
90 +### Responsive Strategy
91 +
92 +#### Breakpoints
93 +
94 +| Name | Width | Key Changes |
95 +|---|---|---|
96 +| Mobile | < 768px | Cover hero 64→40 px; all grids 1-up; hamburger nav. |
97 +| Tablet | 768–1023px | 2-up secondary story grid. |
98 +| Desktop | ≥ 1024px | Full magazine grid. |
99 +
100 +#### Touch Targets
101 +Button-primary renders ~44 px tall (12 vertical padding + 20 line). WCAG AAA at all widths.
102 +
103 +#### Collapsing Strategy
104 +- Nav: full link row + Subscribe CTA at desktop. Hamburger at mobile.
105 +- Magazine grid: hero stays full-width; 2-up secondary drops to 1-up at mobile.
106 +- Story rows: stay single-column at all viewports.
107 +
108 +#### Image Behavior
109 +- Cover images: full-bleed 16:9 hero / 4:3 secondary.
110 +- Article body images: full-width inside the article column.
111 +- Author avatars: small inline circular crops next to bylines.
112 +
113 +## Elevation & Depth
114 +
115 +| Level | Treatment | Use |
116 +|---|---|---|
117 +| Level 0 — Flat | No shadow, no border. | Default — almost every surface lives at this level. |
118 +| Level 1 — Hairline | 1 px solid `{colors.hairline}` border. | Story-row dividers, input borders. |
119 +| Level 2 — Heavy Black Border | 2 px solid `{colors.ink}` border. | Subscribe CTA on certain campaign moments. |
120 +
121 +The brand uses no drop-shadows. Surface contrast and hairline borders carry all visual hierarchy.
122 +
123 +## Shapes
124 +
125 +### Border Radius Scale
126 +
127 +| Token | Value | Use |
128 +|---|---|---|
129 +| `{rounded.none}` | 0px | Every interactive shape — buttons, inputs, cards. The brand's signature square geometry. |
130 +| `{rounded.full}` | 9999px | Circular icon containers only (social-share, account avatar). |
131 +
132 +### Photography Geometry
133 +- Cover stories: 16:9 hero, edge-to-edge.
134 +- Secondary story cards: 4:3 thumbnails.
135 +- Article body images: native aspect, full column width.
136 +- Bylines / avatars: circular `{rounded.full}` 28 px crops.
137 +
138 +## Components
139 +
140 +### Buttons
141 +
142 +**`button-primary`** — the square black CTA.
143 +- Background `{colors.primary}`, text `{colors.on-primary}`, label `{typography.button-md}` (Apercu 16 px / 700 / 0.3 px tracking), padding `{spacing.md} {spacing.xl}`, shape `{rounded.none}` 0 px.
144 +
145 +**`button-outline`** — the white outline CTA.
146 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, same typography / padding / shape.
147 +
148 +**`button-icon-circular`** — the circular share-icon button.
149 +- Background `{colors.canvas}`, ink icon, shape `{rounded.full}`.
150 +
151 +### Cards & Containers
152 +
153 +**`story-card-large`** — the cover-story card with `{typography.display-md}` headline.
154 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.lg}`. No border — the card lives on the canvas with the photo doing the work.
155 +
156 +**`story-card`** — the secondary story card.
157 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md}`. Photo at top, sans display heading + body lead below.
158 +
159 +**`story-row`** — the bylined story list row.
160 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` bottom border, body in `{typography.body-md-strong}`, padding `{spacing.lg}` 0.
161 +
162 +### Inputs & Forms
163 +
164 +**`text-input`** — the standard text input.
165 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, body in `{typography.body-md}`, padding `{spacing.md} {spacing.lg}`, shape `{rounded.none}`.
166 +
167 +### Navigation
168 +
169 +**`nav-bar`** — the top nav, light by default.
170 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.xl}`. Layout: hamburger left, masthead centre, Subscribe right.
171 +
172 +**`nav-link`** — link items inside nav.
173 +- Text `{colors.ink}`, set in `{typography.body-sm-strong}` (Apercu 14 / 700).
174 +
175 +**`footer`** — the black footer band.
176 +- Background `{colors.primary}`, text `{colors.on-primary}`, padding `{spacing.4xl} {spacing.xl}`. Body in `{typography.body-sm}` (Apercu 14 / 400). Footer column eyebrows in `{typography.body-sm-strong}`.
177 +
178 +### Signature Components
179 +
180 +**`hero-band`** — the white hero band hosting the cover-story.
181 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.4xl} {spacing.xl}`. Cover headline in `{typography.display-hero}` (WiredDisplay 64 px).
182 +
183 +**`masthead-band`** — the thin top band with the wordmark.
184 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.xl}`. The wordmark sits centred; flanked by section nav.
185 +
186 +**`category-eyebrow`** — the small uppercase category label above story headlines.
187 +- Text `{colors.ink}`, set in `{typography.body-sm-strong}` (Apercu 14 / 700 — though some campaigns use ALL CAPS via CSS).
188 +
189 +**`byline-row`** — the article byline strip.
190 +- Background `{colors.canvas}`, text `{colors.body}`, body in `{typography.byline}` (BreveText 12.73 / 700 / line-height 2.2). Includes an author avatar + name + date.
191 +
192 +**`hairline-divider`** — the 1 px line between story rows.
193 +- 1 px solid `{colors.hairline}`.
194 +
195 +### Examples (illustrative)
196 +
197 +> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass.
198 +
199 +**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface.
200 +- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding`
201 +
202 +**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode).
203 +- Properties: `backgroundColor`, `textColor`, `rounded`, `padding`
204 +
205 +**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery).
206 +- Properties: `backgroundColor`, `rounded`, `padding`
207 +
208 +**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart).
209 +- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider`
210 +
211 +**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator.
212 +- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding`
213 +
214 +**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm.
215 +- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder`
216 +
217 +**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside.
218 +- Properties: `backgroundColor`, `rounded`, `padding`
219 +
220 +**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow.
221 +- Properties: `backgroundColor`, `rounded`, `padding`
222 +
223 +**`ex-empty-state-card`** — Empty-state illustration frame.
224 +- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography`
225 +
226 +**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow.
227 +- Properties: `backgroundColor`, `rounded`, `padding`, `typography`
228 +
229 +
230 +## Do's and Don'ts
231 +
232 +### Do
233 +- Reserve `{colors.primary}` black for the wordmark, every CTA, and the footer fill. The brand IS the strict black-on-white duet.
234 +- Set hero headlines in `{typography.display-hero}` (WiredDisplay 64 px weight 400). The proprietary serif IS the brand's typographic signature.
235 +- Use `{rounded.none}` 0 px on every button and form input. The brand reads as a printed magazine — square corners are non-negotiable.
236 +- Pair WiredDisplay (serif display) with BreveText (serif body) and Apercu (sans labels). Three faces, three roles.
237 +- Render story rows with `{colors.hairline}` 1 px dividers — the brand's only elevation cue.
238 +
239 +### Don't
240 +- Don't introduce a chromatic brand accent. The link blue is reserved for inline body links inside articles only.
241 +- Don't round button corners. The brand never softens its rectangular geometry.
242 +- Don't drop a soft drop-shadow on cards. Surface contrast and hairlines carry elevation.
243 +- Don't substitute the proprietary serif faces with a generic sans for display. The serif voice is the brand.
244 +- Don't promote display weight beyond 400. The brand's elegance is in the typeface design, not bold weight.
A docs/plans/mini_crm_card2vcf_ff17681c.plan.md
+1314 -0
@@ -0,0 +1,1314 @@
1 +---
2 +name: Mini CRM Card2vcf
3 +overview: "Plan d’implémentation détaillé TDD : mini-CRM Room (carnet, FTS, doublons, import VCF 3+4 / export 3, rescan) — hors-ligne, sans GMS."
4 +todos:
5 + - id: crm-gradle
6 + content: "T0: Room KSP Navigation deps + Application singleton DB"
7 + status: pending
8 + - id: crm-room
9 + content: "T1–T3: Entity FTS exclusions ImageStore Repository + tests"
10 + status: pending
11 + - id: crm-dupes-domain
12 + content: "T4: DuplicateDetector ContactMerge Normalizers + tests"
13 + status: pending
14 + - id: crm-vcard
15 + content: "T5: VCardParser 3.0+4.0 + fixtures + tests"
16 + status: pending
17 + - id: crm-nav-carnet
18 + content: "T6–T7: NavHost home carnet tri rail recherche FAB"
19 + status: pending
20 + - id: crm-fiche
21 + content: "T8–T9: Pager gestes édition rescan"
22 + status: pending
23 + - id: crm-dupes-ui
24 + content: "T10: Puce + DuplicateReview + fusion UI"
25 + status: pending
26 + - id: crm-draft-import
27 + content: "T11–T12: Draft save/update + import VCF anti-doublons"
28 + status: pending
29 + - id: crm-docs-tests
30 + content: "T13: README strings empty states assemble + test suite"
31 + status: pending
32 +isProject: true
33 +---
34 +
35 +# Mini-CRM Card2vcf — Implementation Plan
36 +
37 +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
38 +
39 +**Goal:** Transformer Card2vcf en mini-CRM local offline : carnet SQLite home, images carte/profil, abcédaire, recherche FTS (notes incluses), fiche swipe + gestes mail/appel, notes/édition/rescan, import VCF (lire 3.0+4.0, écrire 3.0) avec anti-doublons et gestion doublons (puce / fusion / exclusion).
40 +
41 +**Architecture:** Room (entity + FTS4 + exclusions) + `ContactRepository` + `DuplicateDetector` ; UI Navigation Compose (home = carnet) ; pipeline OCR existant branché en insert ou update (`editingContactId`) ; images dans `filesDir/contacts/{id}/`.
42 +
43 +**Tech Stack:** Kotlin 2.0, Compose BOM 2024.09, Room 2.6.1 + KSP, Navigation Compose 2.8.x, CameraX/OpenCV/Tesseract existants, JUnit + Robolectric, minSdk 31, package `fr.ebii.card2vcf`, design [`design.md`](design.md).
44 +
45 +**Repo racine :** `/home/monsieurb/Documents/devel/EBII_mobileVCF`
46 +**Tests :** `cd android && ./gradlew :app:testDebugUnitTest`
47 +**Build :** `cd android && ./gradlew :app:assembleDebug`
48 +
49 +---
50 +
51 +## Décisions produit (non négociables)
52 +
53 +- Home = **carnet** ; `+` = Scanner | Saisie manuelle | Import VCF
54 +- Save local **systématique** ; Contacts Android + export VCF **optionnels**
55 +- Tri : Nom | Prénom | Entreprise (groupé + rail A–Z) | Date (sans rail)
56 +- Recherche FTS inclut **notes** ; rail masqué si query non vide
57 +- Doublons : email OU téléphone normalisé ; exclusions persistantes ; puce `N doublon(s)`
58 +- Import : skip si match ; résumé imported / skippedDuplicates / errors
59 +- vCard : **lire 3.0+4.0**, **écrire 3.0** seulement ; pas d’import `PHOTO`
60 +- Rescan : remplace `card.jpg` + champs ; **notes conservées** par défaut
61 +
62 +**Hors v1 :** sync cloud, tags CRM, matching flou sans email/tél, écriture vCard 4.0, `PHOTO` VCF.
63 +
64 +---
65 +
66 +## Carte des fichiers
67 +
68 +```text
69 +android/app/src/main/java/fr/ebii/card2vcf/
70 +├── Card2vcfApp.kt # NEW Application + DB singleton
71 +├── MainActivity.kt # MOD NavHost
72 +├── contact/
73 +│ ├── ContactCard.kt # EXIST
74 +│ ├── VCardSerializer.kt # EXIST écriture 3.0
75 +│ └── VCardParser.kt # NEW lecture 3+4
76 +├── data/
77 +│ ├── Converters.kt
78 +│ ├── CrmContactEntity.kt
79 +│ ├── CrmContactFts.kt
80 +│ ├── DuplicateExclusionEntity.kt
81 +│ ├── CrmContactDao.kt
82 +│ ├── CrmDatabase.kt
83 +│ ├── ContactImageStore.kt
84 +│ └── ContactRepository.kt
85 +├── crm/
86 +│ ├── ContactSort.kt
87 +│ ├── ContactNormalizer.kt
88 +│ ├── AlphabetIndex.kt
89 +│ ├── DuplicateDetector.kt
90 +│ └── ContactMerge.kt
91 +└── ui/
92 + ├── nav/Card2vcfNavHost.kt
93 + ├── carnet/{CarnetScreen,AlphabetRail,CarnetViewModel}.kt
94 + ├── contact/{ContactPagerScreen,ContactEditScreen,DuplicateReviewScreen,*ViewModel}.kt
95 + ├── import/{VcfImportScreen,VcfImportViewModel}.kt
96 + ├── ContactDraftScreen.kt # MOD
97 + └── ScanCarteViewModel.kt # MOD editingContactId
98 +
99 +android/app/src/test/java/fr/ebii/card2vcf/
100 +├── data/ContactRepositoryTest.kt
101 +├── crm/{ContactNormalizer,DuplicateDetector,ContactMerge,AlphabetIndex}Test.kt
102 +└── contact/VCardParserTest.kt (+ resources/vcf/*.vcf)
103 +```
104 +
105 +---
106 +
107 +### Task 0: Gradle Room + KSP + Navigation + Application
108 +
109 +**Files:**
110 +- Modify: `android/build.gradle.kts`
111 +- Modify: `android/app/build.gradle.kts`
112 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt`
113 +- Modify: `android/app/src/main/AndroidManifest.xml`
114 +
115 +- [ ] **Step 1: Ajouter le plugin KSP racine**
116 +
117 +Dans `android/build.gradle.kts` :
118 +
119 +```kotlin
120 +plugins {
121 + id("com.android.application") version "8.6.0" apply false
122 + id("org.jetbrains.kotlin.android") version "2.0.20" apply false
123 + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
124 + id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false
125 +}
126 +```
127 +
128 +- [ ] **Step 2: Dépendances app**
129 +
130 +Dans `android/app/build.gradle.kts` plugins :
131 +
132 +```kotlin
133 +plugins {
134 + id("com.android.application")
135 + id("org.jetbrains.kotlin.android")
136 + id("org.jetbrains.kotlin.plugin.compose")
137 + id("com.google.devtools.ksp")
138 +}
139 +```
140 +
141 +Dans `dependencies` ajouter :
142 +
143 +```kotlin
144 +val room = "2.6.1"
145 +implementation("androidx.room:room-runtime:$room")
146 +implementation("androidx.room:room-ktx:$room")
147 +ksp("androidx.room:room-compiler:$room")
148 +implementation("androidx.navigation:navigation-compose:2.8.2")
149 +testImplementation("androidx.room:room-testing:$room")
150 +```
151 +
152 +- [ ] **Step 3: Application + Manifest**
153 +
154 +```kotlin
155 +package fr.ebii.card2vcf
156 +
157 +import android.app.Application
158 +import fr.ebii.card2vcf.data.CrmDatabase
159 +
160 +class Card2vcfApp : Application() {
161 + val database: CrmDatabase by lazy { CrmDatabase.get(this) }
162 +}
163 +```
164 +
165 +Manifest : `android:name=".Card2vcfApp"` sur `<application>`.
166 +
167 +- [ ] **Step 4: Vérifier resolve**
168 +
169 +Run: `cd android && ./gradlew :app:dependencies --configuration debugCompileClasspath | head -n 5`
170 +Expected: exit 0 (après Task 1 pour classes Room, `assembleDebug` peut encore fail tant que DB absente — OK après T1).
171 +
172 +- [ ] **Step 5: Commit**
173 +
174 +```bash
175 +git add android/build.gradle.kts android/app/build.gradle.kts \
176 + android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt \
177 + android/app/src/main/AndroidManifest.xml
178 +git commit -m "$(cat <<'EOF'
179 +build: Room KSP Navigation et Application Card2vcf
180 +
181 +EOF
182 +)"
183 +```
184 +
185 +---
186 +
187 +### Task 1: Modèle Room + Converters (TDD)
188 +
189 +**Files:**
190 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/Converters.kt`
191 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactEntity.kt`
192 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactFts.kt`
193 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/DuplicateExclusionEntity.kt`
194 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt`
195 +
196 +- [ ] **Step 1: Test Converters (rouge)**
197 +
198 +```kotlin
199 +package fr.ebii.card2vcf.data
200 +
201 +import org.junit.Assert.assertEquals
202 +import org.junit.Test
203 +
204 +class ConvertersTest {
205 + private val c = Converters()
206 +
207 + @Test
208 + fun roundTripStringList() {
209 + val list = listOf("a@b.c", "x@y.z")
210 + assertEquals(list, c.toStringList(c.fromStringList(list)))
211 + }
212 +
213 + @Test
214 + fun nullOrEmpty() {
215 + assertEquals(emptyList<String>(), c.toStringList(null))
216 + assertEquals("[]", c.fromStringList(emptyList()))
217 + }
218 +}
219 +```
220 +
221 +- [ ] **Step 2: Run — expect FAIL**
222 +
223 +Run: `cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest`
224 +Expected: FAIL compilation / class missing.
225 +
226 +- [ ] **Step 3: Implémenter Converters + entities**
227 +
228 +```kotlin
229 +package fr.ebii.card2vcf.data
230 +
231 +import androidx.room.TypeConverter
232 +import org.json.JSONArray
233 +
234 +class Converters {
235 + @TypeConverter
236 + fun fromStringList(value: List<String>?): String {
237 + val arr = JSONArray()
238 + (value ?: emptyList()).forEach { arr.put(it) }
239 + return arr.toString()
240 + }
241 +
242 + @TypeConverter
243 + fun toStringList(value: String?): List<String> {
244 + if (value.isNullOrBlank()) return emptyList()
245 + val arr = JSONArray(value)
246 + return buildList {
247 + for (i in 0 until arr.length()) add(arr.getString(i))
248 + }
249 + }
250 +}
251 +```
252 +
253 +```kotlin
254 +package fr.ebii.card2vcf.data
255 +
256 +import androidx.room.Entity
257 +import androidx.room.PrimaryKey
258 +
259 +@Entity(tableName = "crm_contacts")
260 +data class CrmContactEntity(
261 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
262 + val fullName: String? = null,
263 + val firstName: String? = null,
264 + val lastName: String? = null,
265 + val company: String? = null,
266 + val jobTitle: String? = null,
267 + val phones: List<String> = emptyList(),
268 + val emails: List<String> = emptyList(),
269 + val website: String? = null,
270 + val address: String? = null,
271 + val notes: String? = null,
272 + val cardImagePath: String? = null,
273 + val profileImagePath: String? = null,
274 + val createdAt: Long = 0L,
275 + val updatedAt: Long = 0L,
276 +)
277 +```
278 +
279 +```kotlin
280 +package fr.ebii.card2vcf.data
281 +
282 +import androidx.room.Entity
283 +import androidx.room.Fts4
284 +
285 +@Fts4(contentEntity = CrmContactEntity::class)
286 +@Entity(tableName = "crm_contacts_fts")
287 +data class CrmContactFts(
288 + val fullName: String?,
289 + val firstName: String?,
290 + val lastName: String?,
291 + val company: String?,
292 + val jobTitle: String?,
293 + val phones: String?,
294 + val emails: String?,
295 + val website: String?,
296 + val address: String?,
297 + val notes: String?,
298 +)
299 +```
300 +
301 +Note Room FTS external content : les colonnes FTS doivent correspondre à des colonnes texte de l’entity. **`phones`/`emails` dans l’entity sont `List<String>`** — pour FTS, ajouter sur l’entity des colonnes miroir dénormalisées **ou** stocker phones/emails uniquement en String JSON déjà présent et indexer ces strings. **Choix figé :** garder `phones`/`emails` comme `List<String>` via converter ; **ne pas** les mettre dans `@Fts4 contentEntity` colonnes List. Utiliser plutôt :
302 +
303 +```kotlin
304 +@Fts4(contentEntity = CrmContactEntity::class)
305 +@Entity(tableName = "crm_contacts_fts")
306 +data class CrmContactFts(
307 + val fullName: String?,
308 + val firstName: String?,
309 + val lastName: String?,
310 + val company: String?,
311 + val jobTitle: String?,
312 + val website: String?,
313 + val address: String?,
314 + val notes: String?,
315 +)
316 +```
317 +
318 +Et pour chercher dans phones/emails : colonnes supplémentaires `phonesText` / `emailsText` (String, join `" "`) mises à jour dans le repository à chaque insert/update, **incluses** dans l’entity + FTS.
319 +
320 +Mettre à jour `CrmContactEntity` :
321 +
322 +```kotlin
323 +val phonesText: String = "", // space-joined, pour FTS
324 +val emailsText: String = "",
325 +```
326 +
327 +- [ ] **Step 4: Exclusion entity**
328 +
329 +```kotlin
330 +package fr.ebii.card2vcf.data
331 +
332 +import androidx.room.Entity
333 +import androidx.room.Index
334 +import androidx.room.PrimaryKey
335 +
336 +@Entity(
337 + tableName = "duplicate_exclusions",
338 + indices = [Index(value = ["idA", "idB"], unique = true)],
339 +)
340 +data class DuplicateExclusionEntity(
341 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
342 + val idA: Long, // always idA < idB
343 + val idB: Long,
344 +)
345 +```
346 +
347 +- [ ] **Step 5: Run tests verts**
348 +
349 +Run: `cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest`
350 +Expected: PASS
351 +
352 +- [ ] **Step 6: Commit**
353 +
354 +```bash
355 +git add android/app/src/main/java/fr/ebii/card2vcf/data/ \
356 + android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt
357 +git commit -m "$(cat <<'EOF'
358 +feat(data): entities Room CRM converters et exclusions doublons
359 +
360 +EOF
361 +)"
362 +```
363 +
364 +---
365 +
366 +### Task 2: DAO + Database
367 +
368 +**Files:**
369 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactDao.kt`
370 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt`
371 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/CrmContactDaoTest.kt`
372 +
373 +- [ ] **Step 1: Test DAO insert + FTS notes (rouge)**
374 +
375 +```kotlin
376 +package fr.ebii.card2vcf.data
377 +
378 +import android.content.Context
379 +import androidx.room.Room
380 +import androidx.test.core.app.ApplicationProvider
381 +import kotlinx.coroutines.runBlocking
382 +import org.junit.After
383 +import org.junit.Assert.assertEquals
384 +import org.junit.Before
385 +import org.junit.Test
386 +import org.junit.runner.RunWith
387 +import org.robolectric.RobolectricTestRunner
388 +
389 +@RunWith(RobolectricTestRunner::class)
390 +class CrmContactDaoTest {
391 + private lateinit var db: CrmDatabase
392 + private lateinit var dao: CrmContactDao
393 +
394 + @Before
395 + fun setUp() {
396 + val ctx = ApplicationProvider.getApplicationContext<Context>()
397 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
398 + .allowMainThreadQueries()
399 + .build()
400 + dao = db.crmContactDao()
401 + }
402 +
403 + @After fun tearDown() = db.close()
404 +
405 + @Test
406 + fun insertAndSearchNotes() = runBlocking {
407 + val id = dao.insert(
408 + CrmContactEntity(
409 + fullName = "Ada",
410 + notes = "salon Lyon 2026",
411 + phonesText = "",
412 + emailsText = "",
413 + createdAt = 1L,
414 + updatedAt = 1L,
415 + ),
416 + )
417 + val hits = dao.searchIds("Lyon")
418 + assertEquals(listOf(id), hits)
419 + }
420 +}
421 +```
422 +
423 +- [ ] **Step 2: Run — expect FAIL**
424 +
425 +- [ ] **Step 3: DAO + Database**
426 +
427 +```kotlin
428 +package fr.ebii.card2vcf.data
429 +
430 +import androidx.room.Dao
431 +import androidx.room.Insert
432 +import androidx.room.OnConflictStrategy
433 +import androidx.room.Query
434 +import androidx.room.Update
435 +import kotlinx.coroutines.flow.Flow
436 +
437 +@Dao
438 +interface CrmContactDao {
439 + @Insert(onConflict = OnConflictStrategy.REPLACE)
440 + suspend fun insert(entity: CrmContactEntity): Long
441 +
442 + @Update
443 + suspend fun update(entity: CrmContactEntity)
444 +
445 + @Query("DELETE FROM crm_contacts WHERE id = :id")
446 + suspend fun deleteById(id: Long)
447 +
448 + @Query("SELECT * FROM crm_contacts WHERE id = :id")
449 + suspend fun getById(id: Long): CrmContactEntity?
450 +
451 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
452 + fun observeAll(): Flow<List<CrmContactEntity>>
453 +
454 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
455 + suspend fun getAll(): List<CrmContactEntity>
456 +
457 + @Query(
458 + """
459 + SELECT rowid FROM crm_contacts_fts
460 + WHERE crm_contacts_fts MATCH :query
461 + """,
462 + )
463 + suspend fun searchIds(query: String): List<Long>
464 +
465 + @Insert(onConflict = OnConflictStrategy.IGNORE)
466 + suspend fun insertExclusion(ex: DuplicateExclusionEntity): Long
467 +
468 + @Query("SELECT * FROM duplicate_exclusions")
469 + suspend fun getExclusions(): List<DuplicateExclusionEntity>
470 +
471 + @Query("DELETE FROM duplicate_exclusions WHERE idA = :id OR idB = :id")
472 + suspend fun deleteExclusionsFor(id: Long)
473 +}
474 +```
475 +
476 +```kotlin
477 +package fr.ebii.card2vcf.data
478 +
479 +import android.content.Context
480 +import androidx.room.Database
481 +import androidx.room.Room
482 +import androidx.room.RoomDatabase
483 +import androidx.room.TypeConverters
484 +
485 +@Database(
486 + entities = [
487 + CrmContactEntity::class,
488 + CrmContactFts::class,
489 + DuplicateExclusionEntity::class,
490 + ],
491 + version = 1,
492 + exportSchema = false,
493 +)
494 +@TypeConverters(Converters::class)
495 +abstract class CrmDatabase : RoomDatabase() {
496 + abstract fun crmContactDao(): CrmContactDao
497 +
498 + companion object {
499 + @Volatile private var instance: CrmDatabase? = null
500 +
501 + fun get(context: Context): CrmDatabase =
502 + instance ?: synchronized(this) {
503 + instance ?: Room.databaseBuilder(
504 + context.applicationContext,
505 + CrmDatabase::class.java,
506 + "card2vcf-crm.db",
507 + ).build().also { instance = it }
508 + }
509 + }
510 +}
511 +```
512 +
513 +**FTS MATCH :** préfixer la query utilisateur avec tokenisation simple — dans le repository, transformer `"Lyon"` en `"Lyon*"` pour préfixe. Documenter dans `ContactRepository.search`.
514 +
515 +Si `contentEntity` FTS pose problème avec converters List : fallback figé — table FTS manuelle synchronisée dans `insert`/`update` via `@Insert` sur `CrmContactFts` **sans** contentEntity (copier les champs texte). Préférer faire passer les tests ; ajuster l’annotation FTS en conséquence.
516 +
517 +- [ ] **Step 4: Run — expect PASS**
518 +
519 +- [ ] **Step 5: Commit**
520 +
521 +```bash
522 +git commit -m "$(cat <<'EOF'
523 +feat(data): DAO Room FTS et CrmDatabase
524 +
525 +EOF
526 +)"
527 +```
528 +
529 +---
530 +
531 +### Task 3: ContactImageStore + ContactRepository
532 +
533 +**Files:**
534 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt`
535 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt`
536 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactNormalizer.kt` (utilisé par repo import)
537 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt`
538 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/crm/ContactNormalizerTest.kt`
539 +
540 +- [ ] **Step 1: Tests Normalizer (rouge puis vert)**
541 +
542 +```kotlin
543 +package fr.ebii.card2vcf.crm
544 +
545 +object ContactNormalizer {
546 + fun email(raw: String): String = raw.trim().lowercase()
547 + fun phone(raw: String): String = raw.filter { it.isDigit() }
548 +}
549 +```
550 +
551 +Tests : `" A@B.C "` → `"a@b.c"` ; `"+33 6 12-34"` → `"3361234"` (digits only).
552 +
553 +- [ ] **Step 2: ImageStore**
554 +
555 +```kotlin
556 +package fr.ebii.card2vcf.data
557 +
558 +import android.content.Context
559 +import android.graphics.Bitmap
560 +import java.io.File
561 +import java.io.FileOutputStream
562 +
563 +class ContactImageStore(private val context: Context) {
564 + private fun dir(id: Long): File =
565 + File(context.filesDir, "contacts/$id").also { it.mkdirs() }
566 +
567 + fun saveJpeg(contactId: Long, name: String, bitmap: Bitmap, quality: Int = 90): String {
568 + val file = File(dir(contactId), name)
569 + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
570 + return file.absolutePath
571 + }
572 +
573 + fun deleteAll(contactId: Long) {
574 + dir(contactId).deleteRecursively()
575 + }
576 +
577 + fun copyImage(fromPath: String?, toContactId: Long, name: String): String? {
578 + if (fromPath.isNullOrBlank()) return null
579 + val src = File(fromPath)
580 + if (!src.isFile) return null
581 + val dest = File(dir(toContactId), name)
582 + src.copyTo(dest, overwrite = true)
583 + return dest.absolutePath
584 + }
585 +}
586 +```
587 +
588 +- [ ] **Step 3: Repository API**
589 +
590 +```kotlin
591 +package fr.ebii.card2vcf.data
592 +
593 +import android.graphics.Bitmap
594 +import fr.ebii.card2vcf.contact.ContactCard
595 +import fr.ebii.card2vcf.crm.ContactNormalizer
596 +import kotlinx.coroutines.flow.Flow
597 +
598 +data class VcfImportResult(
599 + val imported: Int,
600 + val skippedDuplicates: Int,
601 + val errors: Int,
602 +)
603 +
604 +class ContactRepository(
605 + private val dao: CrmContactDao,
606 + private val images: ContactImageStore,
607 +) {
608 + fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll()
609 +
610 + suspend fun getById(id: Long) = dao.getById(id)
611 +
612 + suspend fun search(query: String): List<CrmContactEntity> {
613 + val q = query.trim()
614 + if (q.isEmpty()) return dao.getAll()
615 + val token = q.split(Regex("\\s+")).joinToString(" ") { "$it*" }
616 + val ids = dao.searchIds(token)
617 + return ids.mapNotNull { dao.getById(it) }
618 + }
619 +
620 + suspend fun insertFromCard(
621 + card: ContactCard,
622 + cardBitmap: Bitmap?,
623 + profileBitmap: Bitmap?,
624 + notesOverride: String? = card.note,
625 + now: Long = System.currentTimeMillis(),
626 + ): Long {
627 + val entity = card.toEntity(notes = notesOverride, now = now)
628 + val id = dao.insert(entity)
629 + var updated = entity.copy(id = id)
630 + if (cardBitmap != null) {
631 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
632 + }
633 + if (profileBitmap != null) {
634 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
635 + }
636 + if (updated != entity.copy(id = id)) dao.update(updated)
637 + return id
638 + }
639 +
640 + suspend fun updateFromCard(
641 + id: Long,
642 + card: ContactCard,
643 + cardBitmap: Bitmap?,
644 + profileBitmap: Bitmap?,
645 + preserveNotesIfBlankDraft: Boolean = true,
646 + now: Long = System.currentTimeMillis(),
647 + ) {
648 + val existing = dao.getById(id) ?: return
649 + val notes = when {
650 + preserveNotesIfBlankDraft && card.note.isNullOrBlank() -> existing.notes
651 + else -> card.note
652 + }
653 + var updated = card.toEntity(notes = notes, now = now).copy(
654 + id = id,
655 + createdAt = existing.createdAt,
656 + cardImagePath = existing.cardImagePath,
657 + profileImagePath = existing.profileImagePath,
658 + )
659 + if (cardBitmap != null) {
660 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
661 + }
662 + if (profileBitmap != null) {
663 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
664 + }
665 + dao.update(updated)
666 + }
667 +
668 + suspend fun delete(id: Long) {
669 + dao.deleteExclusionsFor(id)
670 + dao.deleteById(id)
671 + images.deleteAll(id)
672 + }
673 +
674 + suspend fun findMatchingId(card: ContactCard, among: List<CrmContactEntity>): Long? {
675 + val emails = card.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }.toSet()
676 + val phones = card.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }.toSet()
677 + for (e in among) {
678 + val eEmails = e.emails.map(ContactNormalizer::email).toSet()
679 + val ePhones = e.phones.map(ContactNormalizer::phone).toSet()
680 + if (emails.any { it in eEmails } || phones.any { it in ePhones }) return e.id
681 + }
682 + return null
683 + }
684 +
685 + suspend fun importCards(cards: List<ContactCard>): VcfImportResult {
686 + var imported = 0
687 + var skipped = 0
688 + var errors = 0
689 + val existing = dao.getAll().toMutableList()
690 + for (card in cards) {
691 + try {
692 + if (!card.hasAnyField()) {
693 + errors++
694 + continue
695 + }
696 + if (findMatchingId(card, existing) != null) {
697 + skipped++
698 + continue
699 + }
700 + val id = insertFromCard(card, null, null)
701 + existing.add(dao.getById(id)!!)
702 + imported++
703 + } catch (_: Exception) {
704 + errors++
705 + }
706 + }
707 + return VcfImportResult(imported, skipped, errors)
708 + }
709 +}
710 +
711 +fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity(
712 + fullName = fullName,
713 + firstName = firstName,
714 + lastName = lastName,
715 + company = company,
716 + jobTitle = jobTitle,
717 + phones = phones,
718 + emails = emails,
719 + website = website,
720 + address = address,
721 + notes = notes,
722 + phonesText = phones.joinToString(" "),
723 + emailsText = emails.joinToString(" "),
724 + createdAt = now,
725 + updatedAt = now,
726 +)
727 +
728 +fun CrmContactEntity.toCard() = ContactCard(
729 + fullName = fullName,
730 + firstName = firstName,
731 + lastName = lastName,
732 + company = company,
733 + jobTitle = jobTitle,
734 + phones = phones,
735 + emails = emails,
736 + website = website,
737 + address = address,
738 + note = notes,
739 +)
740 +```
741 +
742 +- [ ] **Step 4: Tests repository** — insert + search notes ; import skip duplicate email ; delete removes row.
743 +
744 +- [ ] **Step 5: Commit**
745 +
746 +```bash
747 +git commit -m "$(cat <<'EOF'
748 +feat(data): ContactRepository ImageStore et import anti-doublons
749 +
750 +EOF
751 +)"
752 +```
753 +
754 +---
755 +
756 +### Task 4: DuplicateDetector + ContactMerge + AlphabetIndex
757 +
758 +**Files:**
759 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/DuplicateDetector.kt`
760 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactMerge.kt`
761 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactSort.kt`
762 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/AlphabetIndex.kt`
763 +- Tests miroirs sous `android/app/src/test/java/fr/ebii/card2vcf/crm/`
764 +
765 +- [ ] **Step 1: Tests Detector**
766 +
767 +Cas : A et B même email → même cluster ; exclusion (minId,maxId) → clusters séparés ; téléphone normalisé match.
768 +
769 +```kotlin
770 +package fr.ebii.card2vcf.crm
771 +
772 +import fr.ebii.card2vcf.data.CrmContactEntity
773 +import fr.ebii.card2vcf.data.DuplicateExclusionEntity
774 +
775 +object DuplicateDetector {
776 + fun clusters(
777 + contacts: List<CrmContactEntity>,
778 + exclusions: List<DuplicateExclusionEntity>,
779 + ): Map<Long, Set<Long>> {
780 + val parent = contacts.associate { it.id to it.id }.toMutableMap()
781 + fun find(x: Long): Long {
782 + var p = x
783 + while (parent[p] != p) p = parent[p]!!
784 + return p
785 + }
786 + fun union(a: Long, b: Long) {
787 + val ra = find(a)
788 + val rb = find(b)
789 + if (ra != rb) parent[ra] = rb
790 + }
791 + val excluded = exclusions.map { it.idA to it.idB }.toSet()
792 + fun excludedPair(a: Long, b: Long): Boolean {
793 + val lo = minOf(a, b)
794 + val hi = maxOf(a, b)
795 + return (lo to hi) in excluded
796 + }
797 + val byEmail = mutableMapOf<String, MutableList<Long>>()
798 + val byPhone = mutableMapOf<String, MutableList<Long>>()
799 + for (c in contacts) {
800 + c.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }
801 + .forEach { byEmail.getOrPut(it) { mutableListOf() }.add(c.id) }
802 + c.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }
803 + .forEach { byPhone.getOrPut(it) { mutableListOf() }.add(c.id) }
804 + }
805 + fun linkGroups(groups: Collection<List<Long>>) {
806 + for (g in groups) {
807 + for (i in 1 until g.size) {
808 + if (!excludedPair(g[0], g[i])) union(g[0], g[i])
809 + }
810 + }
811 + }
812 + linkGroups(byEmail.values)
813 + linkGroups(byPhone.values)
814 + val rootToMembers = mutableMapOf<Long, MutableSet<Long>>()
815 + for (c in contacts) {
816 + rootToMembers.getOrPut(find(c.id)) { mutableSetOf() }.add(c.id)
817 + }
818 + val idToCluster = mutableMapOf<Long, Set<Long>>()
819 + for (members in rootToMembers.values) {
820 + for (id in members) idToCluster[id] = members
821 + }
822 + return idToCluster
823 + }
824 +
825 + fun duplicateCount(contactId: Long, clusters: Map<Long, Set<Long>>): Int =
826 + ((clusters[contactId]?.size ?: 1) - 1).coerceAtLeast(0)
827 +}
828 +```
829 +
830 +- [ ] **Step 2: ContactMerge**
831 +
832 +```kotlin
833 +package fr.ebii.card2vcf.crm
834 +
835 +import fr.ebii.card2vcf.data.CrmContactEntity
836 +
837 +data class MergeFieldChoices(
838 + val fullNameFromId: Long,
839 + val firstNameFromId: Long,
840 + val lastNameFromId: Long,
841 + val companyFromId: Long,
842 + val jobTitleFromId: Long,
843 + val websiteFromId: Long,
844 + val addressFromId: Long,
845 + val notesMode: NotesMode, // KEEP_PRIMARY | CONCAT
846 + val cardImageFromId: Long?,
847 + val profileImageFromId: Long?,
848 + val keepId: Long,
849 +)
850 +
851 +enum class NotesMode { KEEP_PRIMARY, CONCAT }
852 +
853 +object ContactMerge {
854 + fun merge(
855 + sources: Map<Long, CrmContactEntity>,
856 + choices: MergeFieldChoices,
857 + now: Long = System.currentTimeMillis(),
858 + ): CrmContactEntity {
859 + fun scalar(picker: Long, sel: (CrmContactEntity) -> String?) =
860 + sources.getValue(picker).let(sel)
861 + val keep = sources.getValue(choices.keepId)
862 + val phones = sources.values.flatMap { it.phones }.distinct()
863 + val emails = sources.values.flatMap { it.emails }.distinct()
864 + val notes = when (choices.notesMode) {
865 + NotesMode.KEEP_PRIMARY -> keep.notes
866 + NotesMode.CONCAT -> sources.values.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }
867 + .distinct()
868 + .joinToString("\n---\n")
869 + .ifBlank { null }
870 + }
871 + return keep.copy(
872 + fullName = scalar(choices.fullNameFromId) { it.fullName },
873 + firstName = scalar(choices.firstNameFromId) { it.firstName },
874 + lastName = scalar(choices.lastNameFromId) { it.lastName },
875 + company = scalar(choices.companyFromId) { it.company },
876 + jobTitle = scalar(choices.jobTitleFromId) { it.jobTitle },
877 + website = scalar(choices.websiteFromId) { it.website },
878 + address = scalar(choices.addressFromId) { it.address },
879 + notes = notes,
880 + phones = phones,
881 + emails = emails,
882 + phonesText = phones.joinToString(" "),
883 + emailsText = emails.joinToString(" "),
884 + updatedAt = now,
885 + // image paths applied by repository after copy
886 + )
887 + }
888 +}
889 +```
890 +
891 +Repository méthodes à ajouter dans la même task ou T10 :
892 +
893 +```kotlin
894 +suspend fun markDifferent(a: Long, b: Long) {
895 + val lo = minOf(a, b); val hi = maxOf(a, b)
896 + dao.insertExclusion(DuplicateExclusionEntity(idA = lo, idB = hi))
897 +}
898 +
899 +suspend fun mergeContacts(
900 + sources: List<CrmContactEntity>,
901 + choices: MergeFieldChoices,
902 +) {
903 + val map = sources.associateBy { it.id }
904 + var merged = ContactMerge.merge(map, choices)
905 + // copy chosen images onto keepId, then delete others
906 + ...
907 +}
908 +```
909 +
910 +- [ ] **Step 3: ContactSort + AlphabetIndex**
911 +
912 +```kotlin
913 +enum class ContactSort { LAST_NAME, FIRST_NAME, COMPANY, CREATED_AT }
914 +
915 +fun sortKey(c: CrmContactEntity, sort: ContactSort): String = when (sort) {
916 + ContactSort.LAST_NAME -> c.lastName ?: c.fullName ?: ""
917 + ContactSort.FIRST_NAME -> c.firstName ?: c.fullName ?: ""
918 + ContactSort.COMPANY -> c.company ?: ""
919 + ContactSort.CREATED_AT -> c.createdAt.toString().padStart(20, '0')
920 +}
921 +
922 +fun sectionLetter(key: String): Char {
923 + val c = key.trim().firstOrNull()?.uppercaseChar()
924 + return if (c != null && c in 'A'..'Z') c else '#'
925 +}
926 +```
927 +
928 +Grouper : `LinkedHashMap<Char, List<CrmContactEntity>>` après sort alphabétique (sauf `CREATED_AT` = liste plate chrono desc, pas de sections).
929 +
930 +- [ ] **Step 4: Tests verts + commit**
931 +
932 +```bash
933 +git commit -m "$(cat <<'EOF'
934 +feat(crm): détection doublons fusion tri et abcédaire
935 +
936 +EOF
937 +)"
938 +```
939 +
940 +---
941 +
942 +### Task 5: VCardParser (lire 3.0 + 4.0)
943 +
944 +**Files:**
945 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt`
946 +- Create: `android/app/src/test/resources/vcf/sample3.vcf`
947 +- Create: `android/app/src/test/resources/vcf/sample4.vcf`
948 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt`
949 +
950 +- [ ] **Step 1: Fixtures**
951 +
952 +`sample3.vcf` :
953 +
954 +```text
955 +BEGIN:VCARD
956 +VERSION:3.0
957 +FN:Alice Martin
958 +N:Martin;Alice;;;
959 +ORG:Acme
960 +EMAIL;TYPE=INTERNET:alice@acme.test
961 +TEL;TYPE=CELL:+33601020304
962 +NOTE:salon Lyon
963 +END:VCARD
964 +BEGIN:VCARD
965 +VERSION:3.0
966 +FN:Bob
967 +EMAIL:bob@unique.test
968 +END:VCARD
969 +```
970 +
971 +`sample4.vcf` :
972 +
973 +```text
974 +BEGIN:VCARD
975 +VERSION:4.0
976 +FN:Alice V4
977 +N:V4;Alice;;;
978 +EMAIL;TYPE=work:alice@acme.test
979 +TEL;TYPE=cell:+33601020304
980 +END:VCARD
981 +```
982 +
983 +- [ ] **Step 2: Tests**
984 +
985 +- `parse(sample3)` → 2 cards, FN/ORG/EMAIL/NOTE
986 +- `parse(sample4)` → 1 card, email/tel
987 +- Unfold : ligne pliée spatiale ` ` en début de ligne suivante
988 +- Ignorer `PHOTO`
989 +- Export roundtrip : `VCardSerializer.toVCard3(parserResult)` contient `VERSION:3.0`
990 +
991 +- [ ] **Step 3: Implémentation minimale**
992 +
993 +```kotlin
994 +object VCardParser {
995 + fun parse(text: String): List<ContactCard> {
996 + val unfolded = unfold(text)
997 + val cards = mutableListOf<ContactCard>()
998 + var buf = mutableListOf<String>()
999 + for (line in unfolded.lineSequence()) {
1000 + val t = line.trimEnd()
1001 + when {
1002 + t.equals("BEGIN:VCARD", true) -> buf = mutableListOf()
1003 + t.equals("END:VCARD", true) -> cards.add(parseOne(buf))
1004 + else -> buf.add(t)
1005 + }
1006 + }
1007 + return cards
1008 + }
1009 +
1010 + private fun unfold(text: String): String = buildString {
1011 + for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) {
1012 + if (line.startsWith(" ") || line.startsWith("\t")) {
1013 + if (isNotEmpty()) setLength(length - 1) // remove previous \n
1014 + append(line.drop(1))
1015 + } else {
1016 + if (isNotEmpty()) append('\n')
1017 + append(line)
1018 + }
1019 + }
1020 + }
1021 +
1022 + private fun parseOne(lines: List<String>): ContactCard {
1023 + // split name/params:value ; handle FN N ORG TITLE TEL EMAIL URL ADR NOTE
1024 + // N: Last;First;...
1025 + // ADR: ignore empty components, join street-like
1026 + ...
1027 + }
1028 +}
1029 +```
1030 +
1031 +Compléter `parseOne` avec extraction robuste `property` = avant `:` en gérant `TEL;TYPE=cell:value` et `item1.EMAIL:value`.
1032 +
1033 +- [ ] **Step 4: Commit**
1034 +
1035 +```bash
1036 +git commit -m "$(cat <<'EOF'
1037 +feat(contact): VCardParser lecture vCard 3.0 et 4.0
1038 +
1039 +EOF
1040 +)"
1041 +```
1042 +
1043 +---
1044 +
1045 +### Task 6: Navigation shell — home = carnet
1046 +
1047 +**Files:**
1048 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt`
1049 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt` (stub empty)
1050 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt`
1051 +- Modify: `MainActivity.kt`
1052 +
1053 +Routes figées :
1054 +
1055 +```kotlin
1056 +object Routes {
1057 + const val Carnet = "carnet"
1058 + const val Fiche = "fiche/{contactId}?sort={sort}"
1059 + const val Edit = "edit/{contactId}"
1060 + const val Duplicates = "duplicates/{contactId}"
1061 + const val Scan = "scan?editId={editId}"
1062 + const val Manual = "manual"
1063 + const val Draft = "draft" // ou état dans backStack scan
1064 + const val ImportVcf = "import_vcf"
1065 +}
1066 +```
1067 +
1068 +- [ ] **Step 1:** `MainActivity` obtient `repo` via `(application as Card2vcfApp).database` + `ContactImageStore` ; `setContent { Card2vcfNavHost(...) }` startDestination = `carnet`.
1069 +
1070 +- [ ] **Step 2:** Stub `CarnetScreen` texte « Carnet » pour smoke `assembleDebug`.
1071 +
1072 +- [ ] **Step 3: Commit**
1073 +
1074 +```bash
1075 +git commit -m "$(cat <<'EOF'
1076 +feat(ui): NavHost Card2vcf home carnet
1077 +
1078 +EOF
1079 +)"
1080 +```
1081 +
1082 +---
1083 +
1084 +### Task 7: Carnet UI — tri, abcédaire, recherche, FAB
1085 +
1086 +**Files:**
1087 +- `CarnetScreen.kt`, `AlphabetRail.kt`, `CarnetViewModel.kt`
1088 +- `res/values/strings.xml` (+ `values-en` si présent)
1089 +
1090 +Wireframe (rappel) :
1091 +
1092 +```text
1093 +CARD2VCF [tri]
1094 +[recherche................]
1095 +sections + rows | A-Z
1096 + | #
1097 + [+]
1098 +```
1099 +
1100 +- [ ] **Step 1: ViewModel** — `sort: ContactSort`, `query: String`, `contacts: StateFlow` depuis `observeAll` ou `search(query)` ; expose `sections: List<Pair<Char, List<CrmContactEntity>>>` ; `letterOffsets`.
1101 +
1102 +- [ ] **Step 2: AlphabetRail** — colonne `A..Z` + `#` ; lettres absentes en `Body` gris (`Ink` soft / `colors.body`) ; `onLetter(Char)` → `LazyListState.animateScrollToItem`.
1103 +
1104 +- [ ] **Step 3: FAB menu** — trois actions Scanner / Manuel / Import (Dialog ou DropdownMenu carré design.md).
1105 +
1106 +- [ ] **Step 4: Masquer rail si `query.isNotBlank()`**.
1107 +
1108 +- [ ] **Step 5: assembleDebug + commit**
1109 +
1110 +```bash
1111 +git commit -m "$(cat <<'EOF'
1112 +feat(ui): carnet tri abcédaire recherche et menu création
1113 +
1114 +EOF
1115 +)"
1116 +```
1117 +
1118 +---
1119 +
1120 +### Task 8: Fiche HorizontalPager + gestes
1121 +
1122 +**Files:**
1123 +- `ui/contact/ContactPagerScreen.kt`
1124 +- `ui/contact/ContactPagerViewModel.kt`
1125 +
1126 +- [ ] **Step 1:** Charger liste **ordonnée selon `sort`** (même ordre que carnet) ; `HorizontalPager` index initial = contactId.
1127 +
1128 +- [ ] **Step 2: Gestes** — `detectTapGestures` sur zone contenu (pas top bar) :
1129 + - `onDoubleTap` → `Intent(ACTION_SENDTO, Uri.parse("mailto:${email}"))`
1130 + - triple tap : compteur taps avec timeout 400 ms → `Intent(ACTION_DIAL, Uri.parse("tel:$phone"))`
1131 + - si email/tel absent → no-op (pas de crash)
1132 +
1133 +- [ ] **Step 3: Afficher** profil, identité, champs, `card.jpg` si path, notes lecture.
1134 +
1135 +- [ ] **Step 4: Chrome** — ← retour, Éditer, puce doublons (branche T10 si count>0).
1136 +
1137 +- [ ] **Step 5: Commit**
1138 +
1139 +```bash
1140 +git commit -m "$(cat <<'EOF'
1141 +feat(ui): fiche contact pager swipe double/triple tap
1142 +
1143 +EOF
1144 +)"
1145 +```
1146 +
1147 +---
1148 +
1149 +### Task 9: Édition + rescan
1150 +
1151 +**Files:**
1152 +- `ContactEditScreen.kt` + ViewModel
1153 +- Modify: `ScanCarteViewModel.kt` — `editingContactId: Long?`
1154 +- Modify: draft flow
1155 +
1156 +- [ ] **Step 1: Edit** — champs = `ContactDraftFields` réutilisés ; notes ; bouton photo profil (TakePicture / GetContent) ; **Rescanner la carte** → `navigate("scan?editId=$id")`.
1157 +
1158 +- [ ] **Step 2: Scan en mode update** — après OCR, draft prérempli ; notes = notes DB ; bouton primary **Mettre à jour** appelle `repo.updateFromCard`.
1159 +
1160 +- [ ] **Step 3: Manuel** — draft vide → `insertFromCard`.
1161 +
1162 +- [ ] **Step 4: Commit**
1163 +
1164 +```bash
1165 +git commit -m "$(cat <<'EOF'
1166 +feat(ui): édition contact notes profil et rescan carte
1167 +
1168 +EOF
1169 +)"
1170 +```
1171 +
1172 +---
1173 +
1174 +### Task 10: UI doublons (puce + revue + fusion)
1175 +
1176 +**Files:**
1177 +- `DuplicateReviewScreen.kt` + ViewModel
1178 +- Brancher puce dans `ContactPagerScreen`
1179 +
1180 +- [ ] **Step 1:** ViewModel calcule `DuplicateDetector.clusters(all, exclusions)[id]` ; count pour puce.
1181 +
1182 +- [ ] **Step 2: Revue** — liste des autres contacts ; actions :
1183 + - Supprimer sélection
1184 + - Contacts différents → `repo.markDifferent`
1185 + - Fusionner → sous-écran choix keepId + pickers champ + images
1186 +
1187 +- [ ] **Step 3: Après fusion** — `repo.mergeContacts` ; navigate fiche keepId.
1188 +
1189 +- [ ] **Step 4: Tests unitaires merge déjà verts ; smoke UI assembleDebug.**
1190 +
1191 +- [ ] **Step 5: Commit**
1192 +
1193 +```bash
1194 +git commit -m "$(cat <<'EOF'
1195 +feat(ui): puce doublons revue fusion et exclusions
1196 +
1197 +EOF
1198 +)"
1199 +```
1200 +
1201 +---
1202 +
1203 +### Task 11: Draft — Enregistrer local prioritaire
1204 +
1205 +**Files:**
1206 +- Modify: `ContactDraftScreen.kt`, `ScanCarteViewModel.kt`, strings
1207 +
1208 +- [ ] **Step 1:** Boutons (ordre) :
1209 + 1. **Enregistrer** / **Mettre à jour** (primary noir)
1210 + 2. Créer contact Android (outline)
1211 + 3. Exporter VCF (outline)
1212 + 4. Ajouter photo profil (optionnel)
1213 +
1214 +- [ ] **Step 2:** Après Enregistrer → `popUpTo(carnet)` inclusif false + éventuellement ouvrir fiche.
1215 +
1216 +- [ ] **Step 3: Commit**
1217 +
1218 +```bash
1219 +git commit -m "$(cat <<'EOF'
1220 +feat(ui): brouillon enregistrement carnet local prioritaire
1221 +
1222 +EOF
1223 +)"
1224 +```
1225 +
1226 +---
1227 +
1228 +### Task 12: Import VCF UI
1229 +
1230 +**Files:**
1231 +- `ui/import/VcfImportScreen.kt`, `VcfImportViewModel.kt`
1232 +
1233 +- [ ] **Step 1:** `ActivityResultContracts.OpenDocument()` mime `text/x-vcard` + `text/vcard` + `*/*` fallback.
1234 +
1235 +- [ ] **Step 2:** Lire URI → string → `VCardParser.parse` → `repo.importCards` → afficher résumé.
1236 +
1237 +- [ ] **Step 3: Commit**
1238 +
1239 +```bash
1240 +git commit -m "$(cat <<'EOF'
1241 +feat(ui): import VCF massif anti-doublons résumé
1242 +
1243 +EOF
1244 +)"
1245 +```
1246 +
1247 +---
1248 +
1249 +### Task 13: Polish, README, suite de tests
1250 +
1251 +**Files:**
1252 +- `README.md`
1253 +- strings empty state carnet
1254 +- vérifier `file_paths.xml` si besoin paths `files/`
1255 +
1256 +- [ ] **Step 1: README** — section Mini-CRM : carnet offline, recherche, import (lire 3+4 / écrire 3), doublons, rescan, pas de sync.
1257 +
1258 +- [ ] **Step 2: Empty state** — « Aucun contact — scanner une carte ou importer un VCF ».
1259 +
1260 +- [ ] **Step 3: Suite**
1261 +
1262 +Run:
1263 +
1264 +```bash
1265 +cd android && ./gradlew :app:testDebugUnitTest :app:assembleDebug
1266 +```
1267 +
1268 +Expected: BUILD SUCCESSFUL ; tous les tests data/crm/contact verts.
1269 +
1270 +- [ ] **Step 4: Checklist manuelle Gherkin** (scénarios du plan produit — scan save, FTS notes, import skip, import 4→export 3, puce fusion, contacts différents, rescan notes).
1271 +
1272 +- [ ] **Step 5: Commit**
1273 +
1274 +```bash
1275 +git commit -m "$(cat <<'EOF'
1276 +docs: README mini-CRM et polish empty states
1277 +
1278 +EOF
1279 +)"
1280 +```
1281 +
1282 +---
1283 +
1284 +## Mapping scénarios → tasks
1285 +
1286 +| Scénario | Tasks |
1287 +|----------|-------|
1288 +| Enregistrer scan → SQLite + card.jpg | T3, T11 |
1289 +| Recherche notes | T2, T7 |
1290 +| Import anti-doublons | T3, T5, T12 |
1291 +| Import 4.0 / export 3.0 | T5, T12 |
1292 +| Puce + fusion | T4, T10 |
1293 +| Contacts différents | T4, T10 |
1294 +| Rescan conserve notes | T3, T9 |
1295 +| Abcédiaire entreprise | T4, T7 |
1296 +| Swipe + gestes | T8 |
1297 +
1298 +---
1299 +
1300 +## Ordre d’exécution recommandé
1301 +
1302 +`T0 → T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → T10 → T11 → T12 → T13`
1303 +
1304 +Chaque task se termine par tests unitaires pertinents verts (ou `assembleDebug` pour UI) + commit atomique.
1305 +
1306 +---
1307 +
1308 +## Self-review
1309 +
1310 +- Spec coverage : toutes décisions produit ont une task.
1311 +- Pas de TBD / option A vs B restants.
1312 +- Types alignés : `CrmContactEntity`, `MergeFieldChoices`, `VcfImportResult`, `ContactSort`.
1313 +- vCard lire 3+4 / écrire 3 couvert T5 + scénario.
1314 +- Doublons import + UI couverts T3/T4/T10/T12.
A docs/plans/ocr-carte-visite-vcf.md
+2019 -0
@@ -0,0 +1,2019 @@
1 +# Plan technique OCR carte de visite vers VCF
2 +
3 +> **Statut :** plan d'implémentation — itération documentaire
4 +> **Dernière mise à jour :** 2026-07-22
5 +> **Décision retenue :** `CameraX` + `OpenCV` + `Tesseract4Android` + `Luciole-1B`
6 +> **Philosophie :** produit fonctionnel complet — pas de MVP, pas de livraison partielle
7 +
8 +## Sommaire
9 +
10 +1. [Objectif](#objectif)
11 +2. [Philosophie produit](#philosophie-produit--pas-de-mvp)
12 +3. [Contexte du dépôt](#contexte-du-dépôt)
13 +4. [Contrainte principale](#contrainte-principale)
14 +5. [Workflow cible](#workflow-cible-recommandé)
15 +6. [Choix techniques retenus](#choix-techniques-retenus)
16 +7. [Problème qualité OCR](#problème-qualité-ocr-et-correction-géométrique)
17 +8. [Recherche technique des solutions](#recherche-technique-des-solutions-mobiles)
18 +9. [Matrice de décision](#matrice-de-décision-synthétique)
19 +10. [Stratégie qualité OCR](#stratégie-qualité-ocr-pour-luciole)
20 +11. [Interfaces techniques](#interfaces-techniques-cibles)
21 +12. [Tickets commit par commit](#tickets-commit-par-commit)
22 +13. [User stories](#plan-itératif-par-user-stories)
23 +14. [Recommandation finale](#recommandation-finale)
24 +15. [Sources](#sources-et-références)
25 +
26 +## Objectif
27 +
28 +Ajouter dans `Luciole` une fonctionnalité de scan de carte de visite qui reste locale sur le téléphone, réutilise `Luciole-1B` pour la structuration sémantique, puis permet :
29 +
30 +- le préremplissage d'un contact Android
31 +- l'export d'un fichier `.vcf`
32 +
33 +## Philosophie produit — pas de MVP
34 +
35 +Ce plan vise un **produit fonctionnel complet**, pas une démo partielle ni un prototype jetable.
36 +
37 +### Principes directeurs
38 +
39 +1. **Pas de livraison à moitié codée** : chaque brique livrée est réellement implémentée, testée et intégrée.
40 +2. **Pas de raccourcis en production** : aucun moteur factice, aucun `TODO` bloquant dans le chemin utilisateur, aucune fausse sortie contact.
41 +3. **Revue régulière obligatoire** : chaque étape se termine par une **porte de qualité** (tests verts + vérification manuelle) avant de passer à la suivante.
42 +4. **Tests d'abord sur la logique critique** : parseurs, scan, OCR, structuration, intents — pas seulement de l'UI.
43 +5. **Périmètre complet dès le départ** : scan dédié, chat, brouillon, Contacts Android, export VCF, benchmark, documentation.
44 +
45 +### Anti-patterns interdits
46 +
47 +| Interdit | Pourquoi |
48 +|---|---|
49 +| `FakeScanEngine` / `FakeOcrEngine` en prod | masque des lacunes d'implémentation |
50 +| retourner un contact hardcodé « pour tester » | fausse la chaîne complète |
51 +| reporter l'intégration chat « à plus tard » | livre un flux incomplet |
52 +| livrer Contacts sans VCF (ou l'inverse) | fonctionnalité métier inachevée |
53 +| commit `WIP` ou `fix later` | dette immédiate, perte de temps ensuite |
54 +| dépendance Google Play « temporaire » | contredit la contrainte produit |
55 +
56 +**Note tests unitaires :** `MockWebServer` (déjà utilisé dans le dépôt) est autorisé **uniquement** pour tester `CerveauServeur` hors appareil. Ce n'est pas un raccourci produit.
57 +
58 +### Portes de qualité entre étapes
59 +
60 +| Porte | Après tickets | Critère de passage |
61 +|---|---|---|
62 +| **R1** | C01-C08 | contrat `ContactCard` + VCF validés par tests, schéma JSON figé |
63 +| **R2** | C09-C18 | pipeline scan+OCR réel sur corpus, pas de stub, `./gradlew test` vert |
64 +| **R3** | C19-C22 | `extractContact()` fonctionne avec serveur local, non-régression 11 actions |
65 +| **R4** | C23-C30 | parcours UI complet capture → brouillon éditable, testé sur appareil |
66 +| **R5** | C31-C40 | Contacts + VCF + déclenchement chat opérationnels |
67 +| **R6** | C41-C43 | benchmark documenté, erreurs couvertes, README à jour |
68 +
69 +**Règle :** ne pas ouvrir l'étape N+1 tant que la porte R(N) n'est pas validée.
70 +
71 +## Contexte du dépôt
72 +
73 +Après lecture de la structure du projet et du `README.md`, les points suivants sont établis :
74 +
75 +- le modèle actuel est `Luciole-1B` appelé via `llama.cpp` sur `127.0.0.1:8080`
76 +- l'application Android est organisée autour de `cerveau`, `mains`, `model`, `ui`
77 +- le cerveau actuel ne traite que du texte et renvoie un `JSON d'action`
78 +- il n'existe aujourd'hui ni OCR, ni pipeline image, ni `vCard/VCF`, ni insertion de contact Android
79 +
80 +## Arborescence utile
81 +
82 +- `README.md`
83 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
84 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
85 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
86 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
87 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
88 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
89 +- `android/app/src/main/java/fr/openllm/luciole/mains/Contacts.kt`
90 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
91 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
92 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
93 +- `android/app/src/main/AndroidManifest.xml`
94 +- `android/app/build.gradle.kts`
95 +- `contract/actions.schema.json`
96 +
97 +## Contrainte principale
98 +
99 +`Luciole-1B` dans ce dépôt est un modèle texte, pas un modèle vision. Il ne peut donc pas faire seul un OCR image réel dans l'application telle qu'elle existe aujourd'hui.
100 +
101 +La conséquence est simple :
102 +
103 +- **non faisable en l'état** : `image -> Luciole seul -> OCR`
104 +- **faisable réalistement** : `image -> moteur OCR local -> texte -> Luciole -> contact structuré`
105 +
106 +## Workflow cible recommandé
107 +
108 +Le workflow cible recommandé est :
109 +
110 +`Image -> scan document local -> correction perspective/rotation -> OCR local -> texte brut -> Luciole -> JSON contact structuré -> écran de validation -> insertion Contacts Android / export VCF`
111 +
112 +### Pourquoi ne pas réutiliser le JSON d'action actuel
113 +
114 +Le `JSON d'action` existant dans le projet sert à représenter des intentions Android fermées, par exemple :
115 +
116 +- `appel`
117 +- `agenda`
118 +- `message`
119 +- `ouvrir`
120 +
121 +Une carte de visite ne produit pas naturellement une action ; elle produit un jeu de données structurées. Il faut donc créer un contrat distinct pour les contacts au lieu de forcer ces données dans `Action.kt`.
122 +
123 +## Contrat métier cible
124 +
125 +Le nouvel objet métier conseillé est un `ContactCard`, avec un JSON cible de ce type :
126 +
127 +```json
128 +{
129 + "full_name": "Jean Dupont",
130 + "first_name": "Jean",
131 + "last_name": "Dupont",
132 + "company": "Acme",
133 + "job_title": "Directeur commercial",
134 + "phones": ["+33612345678"],
135 + "emails": ["jean.dupont@acme.fr"],
136 + "website": "https://acme.fr",
137 + "address": "12 rue Exemple, 75000 Paris",
138 + "note": "Informations ambiguës ou texte résiduel"
139 +}
140 +```
141 +
142 +## Architecture cible
143 +
144 +```mermaid
145 +flowchart TD
146 + user[Utilisateur] --> scanFlow[ScanCarteScreen]
147 + user --> chatFlow[ChatLuciole]
148 + scanFlow --> scanEngine[ScanDocumentLocal]
149 + scanEngine --> correctedImage[ImageCorrigee]
150 + correctedImage --> ocrEngine[OcrLocal]
151 + ocrEngine --> rawText[TexteOCR]
152 + chatFlow --> intentRouter[CerveauAction]
153 + intentRouter --> openScan[ActionScannerCarte]
154 + rawText --> contactPrompt[PromptStructurationContact]
155 + contactPrompt --> luciole[Luciole1B]
156 + luciole --> contactJson[ContactCardJson]
157 + contactJson --> draft[ContactDraft]
158 + draft --> review[ValidationUtilisateur]
159 + review --> contactInsert[InsertionContactsAndroid]
160 + review --> vcfExport[ExportVCF]
161 +```
162 +
163 +## Choix techniques retenus
164 +
165 +Cette section fixe les décisions d'implémentation pour `Luciole`, avec un comparatif par couche afin d'expliquer pourquoi chaque choix a été retenu.
166 +
167 +### Contrainte directrice
168 +
169 +Le plan vise une **indépendance réelle** :
170 +
171 +- Android générique
172 +- sans `Google Play Services`
173 +- sans SDK propriétaire obligatoire
174 +- traitement local sur l'appareil
175 +
176 +Il n'y a donc **pas de variante Android standard** dans ce plan.
177 +
178 +### Pile retenue
179 +
180 +```text
181 +CameraX
182 +-> OpenCvScanEngine
183 +-> TesseractOcrEngine
184 +-> OcrPostProcessor
185 +-> Luciole-1B
186 +-> ContactCard
187 +-> validation utilisateur
188 +-> Contacts Android / export VCF
189 +```
190 +
191 +### Comparatif par couche
192 +
193 +#### 1. Capture caméra
194 +
195 +| Option | Sans Play Services | Open source | Maturité | Complexité | Décision |
196 +|---|---:|---:|---:|---:|---|
197 +| **CameraX** | oui | oui | excellente | moyenne | **retenu** |
198 +| Camera2 API directe | oui | oui | bonne | élevée | rejeté |
199 +| Intent système `IMAGE_CAPTURE` | oui | oui | bonne | faible | rejeté |
200 +
201 +**Pourquoi `CameraX`**
202 +
203 +- API Android moderne et maintenue
204 +- bonne intégration avec Compose et `ActivityResult`
205 +- pas de dépendance Google Play
206 +- plus simple et plus robuste qu'une implémentation Camera2 maison
207 +
208 +**Rejet des alternatives**
209 +
210 +- `Camera2` : plus de code bas niveau, plus fragile
211 +- intent système : moins de contrôle sur la qualité de capture et le flux UX
212 +
213 +#### 2. Scan documentaire / correction géométrique
214 +
215 +| Option | Sans Play Services | Open source | Perspective / deskew | Licence | Maturité | Décision |
216 +|---|---:|---:|---:|---|---:|---|
217 +| **OpenCV custom (`OpenCvScanEngine`)** | oui | oui | oui | Apache 2.0 | bonne | **retenu** |
218 +| `AndroidDocumentScanner` | oui | oui | oui | MIT | moyenne | inspiration / fallback |
219 +| `OpenNoteScanner` dérivé | oui | oui | oui | GPLv3 | bonne | rejeté |
220 +| `trudido-scanner` | oui | oui | oui | GPLv3 | faible | rejeté |
221 +| ML Kit Document Scanner | non | non | oui | propriétaire | excellente | hors cible |
222 +
223 +**Pourquoi `OpenCV` en implémentation maison**
224 +
225 +- seule brique réellement indépendante et maîtrisable
226 +- couvre le besoin critique : détection des bords, homographie, crop, rotation, deskew
227 +- s'intègre proprement derrière `ScanEngine`
228 +- évite une dépendance GPL ou un projet trop jeune
229 +
230 +**Rôle d'`AndroidDocumentScanner`**
231 +
232 +- ne pas l'intégrer directement dans la première itération si `OpenCvScanEngine` converge assez vite
233 +- s'en servir comme **référence d'implémentation** si `OpenCvScanEngine` tarde à converger
234 +- licence MIT plus souple si un jour on veut factoriser la détection de document
235 +
236 +**Rejet des alternatives**
237 +
238 +- `OpenNoteScanner` / `trudido-scanner` : utiles en référence, mais GPL ou maturité insuffisante pour une intégration produit directe
239 +- `ML Kit` : hors cible car dépendant de Google Play Services
240 +
241 +#### 3. OCR
242 +
243 +| Option | Sans Play Services | Open source | Qualité OCR | Taille / perf | Licence | Décision |
244 +|---|---:|---:|---:|---:|---|---|
245 +| **Tesseract4Android** | oui | oui | correcte | bonne | Apache 2.0 | **retenu** |
246 +| PaddleOCR Android | oui | oui | meilleure | plus lourde | Apache 2.0 | plan B |
247 +| OpenCV seul | oui | oui | non | — | — | rejeté |
248 +| ML Kit Text Recognition | non | non | bonne | bonne | propriétaire | hors cible |
249 +
250 +**Pourquoi `Tesseract4Android`**
251 +
252 +- wrapper Android moderne autour de Tesseract 5.x
253 +- licence Apache 2.0
254 +- intégration simple dans une app Kotlin
255 +- suffisant pour des cartes de visite, surtout si l'image est déjà corrigée en amont
256 +- cohérent avec l'architecture `OcrEngine`
257 +
258 +**Pourquoi pas `PaddleOCR` en premier choix**
259 +
260 +- meilleure qualité OCR potentielle
261 +- mais intégration plus lourde
262 +- modèles ONNX plus volumineux
263 +- ne résout pas à lui seul le problème de perspective / angle
264 +
265 +**Plan B**
266 +
267 +- si le benchmark montre que Tesseract est insuffisant sur le corpus réel, remplacer uniquement `TesseractOcrEngine` par `PaddleOcrEngine`
268 +
269 +#### 4. Structuration sémantique
270 +
271 +| Option | Rôle | Décision |
272 +|---|---|---|
273 +| **Luciole-1B** | transformer le texte OCR en `ContactCard` | **retenu** |
274 +| Regex / heuristiques seules | extraire téléphone, e-mail, URL | complément obligatoire |
275 +| `JSON d'action` existant | router des intents Android | rejeté pour ce flux |
276 +
277 +**Pourquoi `Luciole-1B`**
278 +
279 +- déjà présent dans l'application
280 +- bon pour distinguer nom, poste, société, adresse à partir d'un texte bruité
281 +- cohérent avec la promesse produit
282 +
283 +**Pourquoi pas le `JSON d'action` actuel**
284 +
285 +- le contrat actuel sert à piloter des actions Android, pas à représenter une fiche contact
286 +
287 +#### 5. Sortie contact
288 +
289 +| Option | Sans Play Services | Complexité | Décision |
290 +|---|---:|---:|---|
291 +| **Préremplissage `Intent.ACTION_INSERT` Contacts** | oui | faible | **retenu** |
292 +| **Export `.vcf` local** | oui | moyenne | **retenu** |
293 +| Création contact silencieuse via `ContentProvider` | oui | élevée | rejeté (validation utilisateur obligatoire) |
294 +
295 +**Pourquoi ce choix**
296 +
297 +- respecte le modèle de sécurité déjà visible dans `Mains.kt`
298 +- l'utilisateur valide avant insertion
299 +- **Contacts et VCF sont tous deux livrés** dans le même périmètre produit (ordre d'implémentation C31 puis C33, pas de report fonctionnel)
300 +
301 +### Décision finale synthétique
302 +
303 +| Couche | Choix retenu | Alternative écartée | Raison principale |
304 +|---|---|---|---|
305 +| Capture | `CameraX` | Camera2 / intent système | modernité et robustesse |
306 +| Scan / géométrie | `OpenCvScanEngine` | ML Kit, Scanbot, Dynamsoft | indépendance et contrôle |
307 +| OCR | `Tesseract4Android` | PaddleOCR (plan B), ML Kit | simplicité + licence + offline |
308 +| Structuration | `Luciole-1B` | heuristiques seules | sémantique contact |
309 +| Complément | `OcrPostProcessor` + regex | — | fiabiliser tel/email/url |
310 +| Sortie | Contacts puis VCF | insertion silencieuse | validation utilisateur |
311 +
312 +### Dépendances Gradle cibles
313 +
314 +```kotlin
315 +// Capture
316 +implementation("androidx.camera:camera-camera2:1.4.1")
317 +implementation("androidx.camera:camera-lifecycle:1.4.1")
318 +implementation("androidx.camera:camera-view:1.4.1")
319 +
320 +// Vision / scan documentaire
321 +implementation("org.opencv:opencv:4.10.0")
322 +
323 +// OCR
324 +implementation("cz.adaptech.tesseract4android:tesseract4android-openmp:4.9.0")
325 +```
326 +
327 +Notes :
328 +
329 +- les versions exactes seront figées au moment de l'implémentation
330 +- les `traineddata` Tesseract (`fra`, `eng`) seront embarqués ou installés localement par l'app
331 +- aucune dépendance `com.google.android.gms` ni `com.google.mlkit` ne doit être ajoutée
332 +
333 +### Modules Kotlin à créer en priorité
334 +
335 +| Module | Rôle |
336 +|---|---|
337 +| `scan/ScanEngine.kt` | contrat scan documentaire |
338 +| `scan/OpenCvScanEngine.kt` | détection bords, homographie, deskew |
339 +| `ocr/OcrEngine.kt` | contrat OCR |
340 +| `ocr/TesseractOcrEngine.kt` | OCR local |
341 +| `ocr/OcrPostProcessor.kt` | tri des lignes, regex, confiance |
342 +| `contact/ContactCard.kt` | modèle métier |
343 +| `contact/ContactCardJson.kt` | parseur JSON contact |
344 +| `cerveau/ContactPrompt.kt` | prompt Luciole dédié |
345 +| `ui/ScanCarteScreen.kt` | parcours utilisateur |
346 +| `ui/ContactDraftScreen.kt` | validation / correction |
347 +
348 +### Ordre d'implémentation technique
349 +
350 +1. `ContactCard` + `VCardSerializer`
351 +2. `OpenCvScanEngine` avec un corpus d'images de test
352 +3. `TesseractOcrEngine`
353 +4. `OcrPostProcessor`
354 +5. `Cerveau.extractContact()`
355 +6. écran scan + brouillon contact
356 +7. insertion Contacts Android
357 +8. export VCF
358 +9. déclenchement depuis le chat
359 +
360 +## Plan d'implémentation détaillé
361 +
362 +Cette section transforme le plan technique en feuille de route exécutable, alignée sur l'architecture décrite dans le `README.md` :
363 +
364 +- `cerveau` pour l'interprétation
365 +- `mains` pour les sorties déterministes
366 +- `ui` pour le parcours utilisateur
367 +
368 +Principe de mise en oeuvre :
369 +
370 +- construire d'abord le **contrat métier**
371 +- brancher ensuite le **pipeline scan + OCR**
372 +- raccorder après cela le **cerveau**
373 +- finir par la **sortie utilisateur** et l'**intégration chat**
374 +
375 +### Phase 0 - Préparation du chantier
376 +
377 +**But**
378 +
379 +Stabiliser le périmètre avant toute ligne de code applicatif.
380 +
381 +**Travaux**
382 +
383 +1. Valider les dépendances autorisées :
384 + - `CameraX`
385 + - `OpenCV`
386 + - `Tesseract4Android`
387 +2. Préparer un petit corpus local de cartes de visite de test :
388 + - droite
389 + - inclinée
390 + - ombrée
391 + - graphique
392 +3. Fixer le contrat `ContactCard` et le format VCF cible.
393 +4. Lister les nouvelles permissions ou capacités Android réellement nécessaires.
394 +
395 +**Livrables**
396 +
397 +- décision technique gelée
398 +- corpus de test minimal
399 +- contrat JSON contact stabilisé
400 +
401 +**Risque à lever**
402 +
403 +- coût d'intégration natif de `OpenCV`
404 +- stratégie d'embarquement des `traineddata` Tesseract
405 +
406 +### Phase 1 - Fondations métier et contrats
407 +
408 +**Stories couvertes**
409 +
410 +- Story 0
411 +- Story 1
412 +
413 +**Objectif**
414 +
415 +Créer les objets métier et les parseurs avant toute intégration caméra ou OCR.
416 +
417 +**Fichiers à créer**
418 +
419 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCard.kt`
420 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCardJson.kt`
421 +- `android/app/src/main/java/fr/openllm/luciole/contact/VCardSerializer.kt`
422 +
423 +**Tests à créer**
424 +
425 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardTest.kt`
426 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardJsonTest.kt`
427 +- `android/app/src/test/java/fr/openllm/luciole/contact/VCardSerializerTest.kt`
428 +
429 +**Tâches détaillées**
430 +
431 +1. Définir `ContactCard` avec champs simples et listes :
432 + - `fullName`
433 + - `firstName`
434 + - `lastName`
435 + - `company`
436 + - `jobTitle`
437 + - `phones`
438 + - `emails`
439 + - `website`
440 + - `address`
441 + - `note`
442 +2. Définir la politique de champs facultatifs :
443 + - `null` pour l'absence
444 + - listes vides pour multi-valeurs absentes
445 +3. Créer `ContactCardJson` :
446 + - parser tolérant
447 + - normalisation minimale
448 + - rejet propre des JSON invalides
449 +4. Créer `VCardSerializer` :
450 + - `VCARD 3.0`
451 + - échappement des séparateurs
452 + - multi-téléphones / multi-e-mails
453 +
454 +**Critères de validation**
455 +
456 +- le contrat contact est indépendant de `Action.kt`
457 +- le VCF produit est importable par Contacts Android
458 +- les tests couvrent caractères accentués, valeurs partielles et multi-champs
459 +
460 +### Phase 2 - Pipeline image local
461 +
462 +**Stories couvertes**
463 +
464 +- Story 2
465 +
466 +**Objectif**
467 +
468 +Obtenir un texte OCR exploitable localement à partir d'une photo de carte.
469 +
470 +**Fichiers à modifier**
471 +
472 +- `android/app/build.gradle.kts`
473 +- `android/app/src/main/AndroidManifest.xml`
474 +
475 +**Fichiers à créer**
476 +
477 +- `android/app/src/main/java/fr/openllm/luciole/scan/ScanEngine.kt`
478 +- `android/app/src/main/java/fr/openllm/luciole/scan/OpenCvScanEngine.kt`
479 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrEngine.kt`
480 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrResult.kt`
481 +- `android/app/src/main/java/fr/openllm/luciole/ocr/TesseractOcrEngine.kt`
482 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrPostProcessor.kt`
483 +
484 +**Tests à créer**
485 +
486 +- `android/app/src/test/java/fr/openllm/luciole/scan/OpenCvScanEngineTest.kt`
487 +- `android/app/src/test/java/fr/openllm/luciole/ocr/OcrPostProcessingTest.kt`
488 +
489 +**Tâches détaillées**
490 +
491 +1. Ajouter les dépendances Gradle et vérifier leur compatibilité avec `minSdk 31`.
492 +2. Définir `ScanEngine` avec une sortie explicite :
493 + - image corrigée
494 + - angle retenu
495 + - score ou drapeau de confiance
496 +3. Implémenter `OpenCvScanEngine` :
497 + - conversion bitmap/mat
498 + - détection du contour principal
499 + - approximation quadrilatère
500 + - homographie
501 + - rotation / deskew
502 +4. Définir `OcrEngine` et `OcrResult`.
503 +5. Implémenter `TesseractOcrEngine` :
504 + - initialisation moteur
505 + - sélection des langues
506 + - OCR bitmap local
507 +6. Implémenter `OcrPostProcessor` :
508 + - nettoyage des lignes
509 + - extraction regex téléphone / e-mail / URL
510 + - fusion des lignes proches si utile
511 +7. Définir les erreurs de pipeline :
512 + - document non détecté
513 + - OCR vide
514 + - modèles de langue absents
515 +
516 +**Critères de validation**
517 +
518 +- le pipeline fonctionne sans `Google Play Services`
519 +- une carte inclinée est redressée avant OCR
520 +- le texte OCR est récupéré même si la structuration Luciole n'est pas encore branchée
521 +
522 +### Phase 3 - Raccordement au cerveau
523 +
524 +**Stories couvertes**
525 +
526 +- Story 3
527 +
528 +**Objectif**
529 +
530 +Réutiliser `Luciole-1B` pour structurer le texte OCR, sans casser le routage des 11 actions existantes.
531 +
532 +**Fichiers à modifier**
533 +
534 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
535 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
536 +
537 +**Fichiers à créer**
538 +
539 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/ContactPrompt.kt`
540 +
541 +**Tests à créer**
542 +
543 +- `android/app/src/test/java/fr/openllm/luciole/cerveau/CerveauServeurContactTest.kt`
544 +
545 +**Tâches détaillées**
546 +
547 +1. Ajouter une nouvelle capacité :
548 + - `suggest()` reste inchangée pour le chat courant
549 + - `extractContact(rawText: String)` est ajoutée à côté
550 +2. Écrire un prompt séparé pour la structuration contact :
551 + - sortie JSON stricte
552 + - pas d'invention de champ absent
553 + - conservation du texte ambigu dans `note`
554 +3. Réutiliser la mécanique HTTP existante de `CerveauServeur`.
555 +4. Parser la réponse avec `ContactCardJson`.
556 +5. Définir une stratégie de fallback :
557 + - si JSON invalide, afficher un brouillon vide mais conserver le texte OCR
558 +
559 +**Critères de validation**
560 +
561 +- aucune régression sur le contrat des 11 actions existantes
562 +- le cerveau peut structurer un texte OCR bruité en `ContactCard`
563 +- le texte brut reste disponible si Luciole échoue
564 +
565 +### Phase 4 - Parcours UI dédié
566 +
567 +**Stories couvertes**
568 +
569 +- Story 4
570 +- Story 6
571 +
572 +**Objectif**
573 +
574 +Créer un parcours utilisateur clair, séparé du chat, cohérent avec la navigation actuelle observée dans `LucioleApp.kt`.
575 +
576 +**Fichiers à modifier**
577 +
578 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
579 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
580 +- `android/app/src/main/res/values/strings.xml`
581 +- `android/app/src/main/res/values-en/strings.xml`
582 +
583 +**Fichiers à créer**
584 +
585 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteScreen.kt`
586 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
587 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftScreen.kt`
588 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftFields.kt`
589 +
590 +**Tâches détaillées**
591 +
592 +1. Ajouter un nouvel état d'écran dans `LucioleApp.kt`.
593 +2. Définir le `ScanCarteViewModel` avec états explicites :
594 + - `Idle`
595 + - `Capturing`
596 + - `Scanning`
597 + - `OcrRunning`
598 + - `Structuring`
599 + - `DraftReady`
600 + - `Error`
601 +3. Construire `ScanCarteScreen` :
602 + - capture CameraX
603 + - aperçu de l'image
604 + - relance en cas d'échec
605 +4. Construire `ContactDraftScreen` :
606 + - champs éditables
607 + - texte OCR brut visible
608 + - boutons `Créer le contact` et `Exporter VCF`
609 +5. Définir les messages d'erreur UX :
610 + - carte mal cadrée
611 + - OCR vide
612 + - structuration partielle
613 +
614 +**Critères de validation**
615 +
616 +- le flux complet est compréhensible sans passer par le chat
617 +- l'utilisateur peut corriger avant toute action de sortie
618 +- l'UI reste cohérente avec le style Compose existant
619 +
620 +### Phase 5 - Sorties Android et VCF
621 +
622 +**Stories couvertes**
623 +
624 +- Story 7
625 +
626 +**Objectif**
627 +
628 +Transformer le brouillon validé en action utile pour l'utilisateur.
629 +
630 +**Fichiers à créer**
631 +
632 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactInsertIntent.kt`
633 +- `android/app/src/main/java/fr/openllm/luciole/contact/VcfShare.kt`
634 +
635 +**Fichiers à modifier**
636 +
637 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
638 +- `android/app/src/main/AndroidManifest.xml`
639 +
640 +**Tests à créer**
641 +
642 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactInsertIntentTest.kt`
643 +
644 +**Tâches détaillées**
645 +
646 +1. Construire l'intent `Intent.ACTION_INSERT` prérempli.
647 +2. Mapper chaque champ `ContactCard` vers les extras Android pertinents.
648 +3. Générer un fichier `.vcf` temporaire partageable.
649 +4. Définir la stratégie de partage :
650 + - `FileProvider` si nécessaire
651 + - nom de fichier stable et lisible
652 +5. Garantir qu'aucune insertion silencieuse n'est faite (validation utilisateur obligatoire).
653 +
654 +**Critères de validation**
655 +
656 +- le contact Android s'ouvre prérempli
657 +- le VCF est partageable et réimportable
658 +- la validation utilisateur reste obligatoire
659 +
660 +### Phase 6 - Intégration au chat et finitions
661 +
662 +**Stories couvertes**
663 +
664 +- Story 5
665 +- Story 8
666 +
667 +**Objectif**
668 +
669 +Raccorder la nouvelle fonctionnalité à la démo existante sans brouiller la responsabilité du chat.
670 +
671 +**Fichiers à modifier**
672 +
673 +- `contract/actions.schema.json`
674 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
675 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
676 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
677 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
678 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
679 +- `README.md`
680 +
681 +**Tâches détaillées**
682 +
683 +1. Ajouter une action `scanner_carte` dans le contrat des actions.
684 +2. Faire en sorte que cette action **ouvre le flux dédié** sans porter les données contact.
685 +3. Ajouter les tests de non-régression sur le parsing d'actions.
686 +4. Mesurer le flux complet sur le corpus :
687 + - succès scan
688 + - précision champs
689 + - temps total
690 +5. Mettre à jour le `README.md` :
691 + - nouvelle fonctionnalité
692 + - limites connues
693 + - dépendances locales
694 +
695 +**Critères de validation**
696 +
697 +- le chat peut déclencher le scan sans devenir un OCR conversationnel
698 +- les 11 actions existantes restent compatibles
699 +- la documentation du dépôt reflète la nouvelle architecture
700 +
701 +### Définition de terminé (produit complet)
702 +
703 +Le chantier est terminé uniquement quand **toutes** les conditions suivantes sont réunies :
704 +
705 +1. une carte de visite peut être photographiée dans l'app
706 +2. l'image est corrigée localement avant OCR (scan réel, pas de placeholder)
707 +3. l'OCR local produit un texte brut exploitable
708 +4. `Luciole-1B` transforme ce texte en `ContactCard`
709 +5. l'utilisateur peut corriger le brouillon avec le texte OCR visible
710 +6. le contact peut être prérempli dans Android **et** exporté en `.vcf`
711 +7. le flux est déclenchable depuis l'écran dédié **et** depuis le chat (`scanner_carte`)
712 +8. le flux reste 100 % local et sans dépendance `Google Play Services`
713 +9. le chat existant ne régresse pas (11 actions + nouvelle action)
714 +10. le benchmark sur corpus est documenté et les cas d'erreur sont couverts par tests
715 +11. `./gradlew :app:testDebugUnitTest` passe sans régression
716 +
717 +## Tickets commit par commit
718 +
719 +Backlog atomique pour l'implémentation. Chaque ticket = **un commit**, **un objectif**, **une vérification**.
720 +
721 +**Tous les tickets C01 à C43 sont obligatoires** pour considérer la fonctionnalité terminée. Aucun n'est optionnel ni reportable.
722 +
723 +Convention de message (alignée sur l'historique du dépôt) :
724 +
725 +```text
726 +feat(scope): description courte
727 +test(scope): ...
728 +docs(scope): ...
729 +build(android): ...
730 +```
731 +
732 +Légende : `verify:` = commande ou contrôle manuel minimal avant de passer au ticket suivant.
733 +
734 +### Phase 0 — Cadrage (Story 0)
735 +
736 +| # | Commit | Fichiers | verify |
737 +|---|---|---|---|
738 +| C01 | `docs(ocr): documenter pipeline OCR local puis structuration Luciole` | `README.md` | le README explique que Luciole ne fait pas l'OCR image seul |
739 +| C02 | `docs(ocr): ajouter contrat JSON ContactCard de reference` | `contract/contact.schema.json` | schéma JSON valide, champs alignés avec le plan |
740 +
741 +### Phase 1 — Contrat contact (Story 1)
742 +
743 +| # | Commit | Fichiers | verify |
744 +|---|---|---|---|
745 +| C03 | `feat(contact): ajouter data class ContactCard` | `contact/ContactCard.kt` | compile |
746 +| C04 | `test(contact): couvrir ContactCard champs partiels et listes` | `ContactCardTest.kt` | `./gradlew :app:testDebugUnitTest --tests '*ContactCardTest'` |
747 +| C05 | `feat(contact): ajouter parseur ContactCardJson` | `contact/ContactCardJson.kt` | compile |
748 +| C06 | `test(contact): couvrir ContactCardJson cas valides et invalides` | `ContactCardJsonTest.kt` | tests verts |
749 +| C07 | `feat(contact): ajouter VCardSerializer VCARD 3.0` | `contact/VCardSerializer.kt` | compile |
750 +| C08 | `test(contact): couvrir VCardSerializer accents et multi-champs` | `VCardSerializerTest.kt` | tests verts, VCF importable manuellement |
751 +
752 +### Phase 2 — Dépendances et permissions (Story 2, début)
753 +
754 +| # | Commit | Fichiers | verify |
755 +|---|---|---|---|
756 +| C09 | `build(android): ajouter dependances CameraX OpenCV Tesseract` | `android/app/build.gradle.kts` | `./gradlew :app:assembleDebug` |
757 +| C10 | `feat(android): declarer permissions camera et stockage scan` | `AndroidManifest.xml` | manifeste valide, pas de GMS/ML Kit |
758 +
759 +### Phase 3 — Scan documentaire (Story 2)
760 +
761 +| # | Commit | Fichiers | verify |
762 +|---|---|---|---|
763 +| C11 | `feat(scan): ajouter interface ScanEngine et ScanResult` | `scan/ScanEngine.kt` | compile |
764 +| C12 | `feat(scan): implementer OpenCvScanEngine detection contour` | `scan/OpenCvScanEngine.kt` | compile |
765 +| C13 | `test(scan): valider redressement sur images de reference` | `OpenCvScanEngineTest.kt`, `src/test/resources/scan/*.jpg` | tests verts sur carte droite + inclinee |
766 +| C14 | `feat(scan): ajouter ajustement manuel des coins` | `OpenCvScanEngine.kt`, `scan/ScanCorners.kt` | image corrigee visible en debug |
767 +
768 +### Phase 4 — OCR local (Story 2)
769 +
770 +| # | Commit | Fichiers | verify |
771 +|---|---|---|---|
772 +| C15 | `feat(ocr): ajouter interface OcrEngine et OcrResult` | `ocr/OcrEngine.kt`, `ocr/OcrResult.kt` | compile |
773 +| C16 | `feat(ocr): implementer TesseractOcrEngine et bootstrap traineddata` | `ocr/TesseractOcrEngine.kt` | OCR texte sur bitmap de test |
774 +| C17 | `feat(ocr): ajouter OcrPostProcessor regex tel email url` | `ocr/OcrPostProcessor.kt` | compile |
775 +| C18 | `test(ocr): couvrir OcrPostProcessor extraction multi-champs` | `OcrPostProcessingTest.kt` | tests verts |
776 +
777 +### Phase 5 — Structuration Luciole (Story 3)
778 +
779 +| # | Commit | Fichiers | verify |
780 +|---|---|---|---|
781 +| C19 | `feat(cerveau): ajouter ContactPrompt structuration contact` | `cerveau/ContactPrompt.kt` | prompt JSON strict, pas d'action melangee |
782 +| C20 | `feat(cerveau): etendre Cerveau avec extractContact` | `cerveau/Cerveau.kt` | interface compile, suggest() inchange |
783 +| C21 | `feat(cerveau): implementer extractContact dans CerveauServeur` | `cerveau/CerveauServeur.kt` | appel HTTP local retourne ContactCard |
784 +| C22 | `test(cerveau): couvrir extractContact avec MockWebServer` | `CerveauServeurContactTest.kt` | tests verts, 11 actions existantes intactes |
785 +
786 +### Phase 6 — UI scan (Story 4)
787 +
788 +| # | Commit | Fichiers | verify |
789 +|---|---|---|---|
790 +| C23 | `feat(ui): ajouter ScanCarteViewModel et machine d etats` | `ui/ScanCarteViewModel.kt` | etats Idle/Scanning/Ocr/Structuring/Draft/Error |
791 +| C24 | `feat(ui): ajouter ScanCarteScreen avec preview CameraX` | `ui/ScanCarteScreen.kt` | capture live sur appareil |
792 +| C25 | `feat(ui): brancher pipeline scan OCR dans ScanCarteViewModel` | `ScanCarteViewModel.kt` | texte OCR affiche apres capture |
793 +| C26 | `feat(ui): ajouter entree navigation Scanner une carte` | `LucioleApp.kt`, `strings.xml`, `strings-en.xml` | ecran accessible depuis l'app |
794 +
795 +### Phase 7 — Brouillon editable (Story 6)
796 +
797 +| # | Commit | Fichiers | verify |
798 +|---|---|---|---|
799 +| C27 | `feat(ui): ajouter ContactDraftFields composables` | `ui/ContactDraftFields.kt` | champs editables nom tel email societe |
800 +| C28 | `feat(ui): ajouter ContactDraftScreen avec texte OCR brut` | `ui/ContactDraftScreen.kt` | correction manuelle possible |
801 +| C29 | `feat(ui): fusionner sortie Luciole et regex dans le brouillon` | `ScanCarteViewModel.kt` | tel/email regex presents meme si LLM partiel |
802 +| C30 | `test(contact): couvrir fusion brouillon OCR regex LLM` | `ContactDraftMergeTest.kt` | tests verts |
803 +
804 +### Phase 8 — Sorties contact (Story 7)
805 +
806 +| # | Commit | Fichiers | verify |
807 +|---|---|---|---|
808 +| C31 | `feat(contact): mapper ContactCard vers Intent ACTION_INSERT` | `contact/ContactInsertIntent.kt` | intent pre-rempli |
809 +| C32 | `test(contact): couvrir extras ContactInsertIntent` | `ContactInsertIntentTest.kt` | tests verts |
810 +| C33 | `feat(contact): ajouter VcfShare generation fichier vcf` | `contact/VcfShare.kt` | fichier .vcf genere |
811 +| C34 | `feat(android): configurer FileProvider pour partage VCF` | `AndroidManifest.xml`, `res/xml/file_paths.xml`, `MainActivity.kt` | partage .vcf vers autre app |
812 +
813 +### Phase 9 — Integration chat (Story 5)
814 +
815 +| # | Commit | Fichiers | verify |
816 +|---|---|---|---|
817 +| C35 | `feat(model): ajouter action scanner_carte au schema` | `contract/actions.schema.json`, `contract/actions.gbnf` | schema + grammaire alignes |
818 +| C36 | `feat(model): propager scanner_carte dans Action et ActionJson` | `model/Action.kt`, `model/ActionJson.kt` | compile |
819 +| C37 | `feat(cerveau): reconnaitre scanner_carte dans SystemPrompt` | `cerveau/SystemPrompt.kt` | prompt route vers action dediee |
820 +| C38 | `feat(mains): router scanner_carte vers ouverture flux scan` | `mains/Mains.kt` | nouvelle Sortie ou signal navigation |
821 +| C39 | `feat(ui): ouvrir ScanCarteScreen depuis le chat` | `ChatScreen.kt`, `MainActivity.kt`, `LucioleApp.kt` | phrase « scanner une carte » ouvre le flux |
822 +| C40 | `test(model): couvrir parsing action scanner_carte` | `ActionScanTest.kt`, `MainsScanTest.kt` | tests verts, non-regression 11 actions |
823 +
824 +### Phase 10 — Qualite et documentation (Story 8)
825 +
826 +| # | Commit | Fichiers | verify |
827 +|---|---|---|---|
828 +| C41 | `test(ocr): ajouter harness benchmark corpus cartes` | `OcrBenchmarkTest.kt`, `src/test/resources/cards/` | metriques scan/OCR mesurables |
829 +| C42 | `test(android): couvrir erreurs pipeline scan OCR vide` | tests cibles scan/ocr/ui | cas echec documentes |
830 +| C43 | `docs(readme): documenter scan carte visite et limites connues` | `README.md` | feature visible, contraintes offline |
831 +
832 +### Étapes de revue (remplace les « jalons démo »)
833 +
834 +| Étape | Tickets | Porte | Livrable validé |
835 +|---|---|---|---|
836 +| **E1 — Contrat** | C01-C08 | R1 | `ContactCard` + VCF + schéma JSON |
837 +| **E2 — Pipeline** | C09-C18 | R2 | scan + OCR réels sur corpus |
838 +| **E3 — Cerveau** | C19-C22 | R3 | structuration Luciole + non-régression |
839 +| **E4 — Parcours UI** | C23-C30 | R4 | capture → brouillon éditable sur appareil |
840 +| **E5 — Sorties** | C31-C34 | — | Contacts Android + export VCF |
841 +| **E6 — Chat** | C35-C40 | R5 | `scanner_carte` + flux complet depuis le chat |
842 +| **E7 — Qualité** | C41-C43 | R6 | benchmark, erreurs, README |
843 +
844 +### Règles de découpage commit
845 +
846 +1. **Un commit = une responsabilité** : pas de mélange parseur + UI + manifest.
847 +2. **Tests dans le commit suivant ou le même** si le commit ajoute une logique testable.
848 +3. **Pas de commit « WIP »** : chaque commit compile et les tests ajoutés passent.
849 +4. **Pas de dépendance Google** dans un commit, même transitoire.
850 +5. **Pas d'implémentation factice** : si une brique n'est pas prête, on ne merge pas le commit UI qui en dépend.
851 +6. Si un ticket dépasse ~200 lignes utiles, le scinder (ex. C12/C14 pour scan).
852 +
853 +### Ordre strict — séquence complète obligatoire
854 +
855 +```text
856 +C01 → C08 → [R1] → C09 → C18 → [R2] → C19 → C22 → [R3]
857 +→ C23 → C30 → [R4] → C31 → C34 → C35 → C40 → [R5] → C41 → C43 → [R6]
858 +```
859 +
860 +Aucune étape ne peut être sautée. L'intégration chat (C35-C40) et la qualité (C41-C43) font partie du produit final, pas d'une phase ultérieure optionnelle.
861 +
862 +## Plan itératif par user stories
863 +
864 +### Story 0 - Cadrer la faisabilité réelle
865 +
866 +**Objectif**
867 +
868 +Verrouiller dans la documentation et dans l'implémentation future que :
869 +
870 +- `Luciole-1B` reste le composant de structuration sémantique
871 +- l'OCR brut est assuré par une brique locale dédiée
872 +- l'OCR image avec `Luciole` seul n'est pas implémentable réalistement dans ce dépôt
873 +
874 +**Fichiers de référence**
875 +
876 +- `README.md`
877 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
878 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
879 +
880 +### Story 1 - Définir le modèle `ContactCard` et le générateur VCF
881 +
882 +**Objectif**
883 +
884 +Créer le contrat de données contact indépendant du contrat `Action`.
885 +
886 +**Fichiers à créer**
887 +
888 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCard.kt`
889 +- `android/app/src/main/java/fr/openllm/luciole/contact/VCardSerializer.kt`
890 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardTest.kt`
891 +- `android/app/src/test/java/fr/openllm/luciole/contact/VCardSerializerTest.kt`
892 +
893 +**Résultat attendu**
894 +
895 +- une structure `ContactCard` réaliste
896 +- un export `VCARD 3.0` robuste
897 +- des tests sur champs partiels, caractères spéciaux et listes de téléphones/e-mails
898 +
899 +### Story 2 - Ajouter un moteur de scan + OCR local derrière des abstractions
900 +
901 +**Objectif**
902 +
903 +Lire le texte d'une carte photographiée sur téléphone, en passant d'abord par une étape de scan document qui corrige angle, perspective et orientation.
904 +
905 +**Décision technique retenue pour le produit**
906 +
907 +- `OpenCvScanEngine` pour la détection des bords, le crop, l'homographie, la rotation et le deskew
908 +- `TesseractOcrEngine` basé sur `Tesseract4Android`
909 +- `CameraX` pour la capture
910 +- aucun composant ne doit dépendre de `Google Play Services`
911 +- format de sortie : image corrigée locale, puis OCR local
912 +
913 +**Fichiers à créer/modifier**
914 +
915 +- `android/app/build.gradle.kts`
916 +- `android/app/src/main/AndroidManifest.xml`
917 +- `android/app/src/main/java/fr/openllm/luciole/scan/ScanEngine.kt`
918 +- `android/app/src/main/java/fr/openllm/luciole/scan/OpenCvScanEngine.kt`
919 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrEngine.kt`
920 +- `android/app/src/main/java/fr/openllm/luciole/ocr/TesseractOcrEngine.kt`
921 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrResult.kt`
922 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrPostProcessor.kt`
923 +- `android/app/src/test/java/fr/openllm/luciole/ocr/OcrPostProcessingTest.kt`
924 +- `android/app/src/test/java/fr/openllm/luciole/scan/OpenCvScanEngineTest.kt`
925 +
926 +**Séquence d'implémentation**
927 +
928 +1. Ajouter les dépendances Gradle pour `CameraX`, `OpenCV` et `Tesseract4Android`.
929 +2. Créer `ScanEngine` / `OpenCvScanEngine` pour :
930 + - détecter le quadrilatère de la carte
931 + - permettre un ajustement manuel des coins
932 + - appliquer la correction de perspective
933 + - remettre le texte à l'horizontale
934 +3. Créer `OcrEngine` / `TesseractOcrEngine` qui traite l'image corrigée locale.
935 +4. Créer `OcrPostProcessor` pour trier les lignes, extraire e-mails/téléphones/URLs.
936 +5. Gérer les erreurs :
937 + - contour non détecté → proposer ajustement manuel ou recapture
938 + - OCR vide → proposer rescan
939 + - langue/modèles Tesseract absents → message clair et installation locale contrôlée par l'app
940 +
941 +**Résultat attendu**
942 +
943 +- le texte OCR est obtenu localement à partir d'une image **déjà corrigée**
944 +- la brique scan et la brique OCR sont encapsulées derrière des interfaces remplaçables
945 +- le post-traitement produit un `OcrResult` structuré prêt pour Luciole
946 +- aucune étape ne dépend d'un service Google ou d'un runtime externe propriétaire
947 +
948 +**Critères d'acceptation**
949 +
950 +- une carte prise en angle produit une image redressée avant OCR
951 +- le texte OCR contient au minimum les lignes visibles sur la carte
952 +- les e-mails et numéros sont extraits par regex même si Luciole échoue ensuite
953 +- le pipeline fonctionne sur un Android générique sans `Google Play Services`
954 +
955 +### Story 3 - Réutiliser `Luciole-1B` pour structurer le texte OCR
956 +
957 +**Objectif**
958 +
959 +Transformer le texte OCR en contact structuré.
960 +
961 +**Fichiers à créer/modifier**
962 +
963 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
964 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
965 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/ContactPrompt.kt`
966 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCardJson.kt`
967 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardJsonTest.kt`
968 +- `android/app/src/test/java/fr/openllm/luciole/cerveau/CerveauServeurContactTest.kt`
969 +
970 +**Résultat attendu**
971 +
972 +- une capacité dédiée du cerveau, par exemple `extractContact(rawText: String)`
973 +- un prompt spécifique de structuration contact
974 +- un parseur JSON séparé de `ActionJson`
975 +
976 +### Story 4 - Créer un écran dédié "Scanner une carte"
977 +
978 +**Objectif**
979 +
980 +Offrir un parcours utilisateur propre, distinct du chat.
981 +
982 +**Fichiers à créer/modifier**
983 +
984 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
985 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
986 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteScreen.kt`
987 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
988 +- `android/app/src/main/res/values/strings.xml`
989 +- `android/app/src/main/res/values-en/strings.xml`
990 +
991 +**Résultat attendu**
992 +
993 +- un point d'entrée dédié
994 +- une séquence `capture -> OCR -> structuration -> prévisualisation`
995 +
996 +### Story 5 - Ajouter le déclenchement depuis le chat
997 +
998 +**Objectif**
999 +
1000 +Permettre à l'utilisateur de lancer le flux dédié depuis Luciole sans mélanger extraction documentaire et routage d'intention.
1001 +
1002 +**Fichiers à créer/modifier**
1003 +
1004 +- `contract/actions.schema.json`
1005 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
1006 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
1007 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
1008 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
1009 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
1010 +- `android/app/src/test/java/fr/openllm/luciole/model/ActionScanTest.kt`
1011 +- `android/app/src/test/java/fr/openllm/luciole/mains/MainsScanTest.kt`
1012 +
1013 +**Résultat attendu**
1014 +
1015 +- une action du type `scanner_carte`
1016 +- cette action ouvre le flux spécialisé sans porter elle-même les données contact
1017 +
1018 +### Story 6 - Ajouter un brouillon éditable de contact
1019 +
1020 +**Objectif**
1021 +
1022 +Éviter toute création erronée de contact à cause d'un OCR imparfait.
1023 +
1024 +**Fichiers à créer/modifier**
1025 +
1026 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftScreen.kt`
1027 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftFields.kt`
1028 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
1029 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactDraftReducerTest.kt`
1030 +
1031 +**Résultat attendu**
1032 +
1033 +- correction manuelle possible
1034 +- affichage du texte OCR brut pour contrôle
1035 +- validation explicite avant toute sortie
1036 +
1037 +### Story 7 - Ajouter l'insertion Android Contacts et l'export VCF
1038 +
1039 +**Objectif**
1040 +
1041 +Finaliser la fonctionnalité métier.
1042 +
1043 +**Fichiers à créer/modifier**
1044 +
1045 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactInsertIntent.kt`
1046 +- `android/app/src/main/java/fr/openllm/luciole/contact/VcfShare.kt`
1047 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
1048 +- `android/app/src/main/AndroidManifest.xml`
1049 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactInsertIntentTest.kt`
1050 +
1051 +**Résultat attendu**
1052 +
1053 +- préremplissage d'un contact Android
1054 +- export d'un fichier `.vcf` partageable
1055 +
1056 +### Story 8 - Robustesse, tests et documentation
1057 +
1058 +**Objectif**
1059 +
1060 +Valider la qualité du flux complet sans dégrader la démo actuelle.
1061 +
1062 +**Corpus de test qualité OCR**
1063 +
1064 +Constituer un jeu de **15 à 20 cartes réelles ou anonymisées** couvrant :
1065 +
1066 +| Cas | Objectif |
1067 +|---|---|
1068 +| carte droite, bonne lumière | baseline |
1069 +| carte inclinée 20-30° | valider correction perspective |
1070 +| carte inclinée 45°+ | limite du système |
1071 +| ombre sur un coin | valider le nettoyage et les filtres locaux |
1072 +| fond chargé (table bois, tissu) | valider edge detection |
1073 +| police fine | stress OCR |
1074 +| carte très graphique / logo dominant | stress OCR + structuration |
1075 +| deux numéros + un e-mail | valider extraction multi-champs |
1076 +| carte bilingue FR/EN | valider script latin |
1077 +| carte sans e-mail | valider champs partiels |
1078 +| photo floue légère | mesurer dégradation |
1079 +| import galerie (pas capture live) | valider parcours alternatif |
1080 +
1081 +**Métriques à mesurer**
1082 +
1083 +| Métrique | Description |
1084 +|---|---|
1085 +| `scan_success_rate` | % de cartes où le document est détecté et recadré |
1086 +| `ocr_char_accuracy` | comparaison manuelle texte OCR vs carte |
1087 +| `field_precision` | % de champs corrects (nom, tel, email, société) |
1088 +| `field_recall` | % de champs présents sur la carte qui sont retrouvés |
1089 +| `end_to_end_time` | capture -> contact prêt à valider |
1090 +| `user_correction_rate` | % de scans nécessitant édition manuelle |
1091 +
1092 +**Seuils de validation produit**
1093 +
1094 +- `scan_success_rate` ≥ 85 % sur corpus standard
1095 +- `field_precision` ≥ 70 % sur téléphone et e-mail
1096 +- `end_to_end_time` ≤ 12 s sur Pixel récent (hors premier chargement modèles)
1097 +
1098 +Ces seuils sont des **critères de finition**, pas une excuse pour livrer sans chat, sans VCF ou sans tests d'erreur.
1099 +
1100 +**Plan d'escalade si la qualité open source est insuffisante**
1101 +
1102 +1. Mesurer sur le corpus quels cas échouent (angle, ombre, police, fond).
1103 +2. Si échecs principalement géométriques → renforcer `OpenCvScanEngine` ou intégrer une bibliothèque open source de scan plus mature.
1104 +3. Si échecs principalement OCR → comparer `Tesseract4Android` et `PaddleOCR`.
1105 +4. Si échecs principalement sémantiques (nom/poste/société) → améliorer `ContactPrompt` et heuristiques, pas le scanner.
1106 +5. Conserver les interfaces `ScanEngine` / `OcrEngine` pour swap fournisseur sans refonte.
1107 +
1108 +**Points à couvrir**
1109 +
1110 +- carte floue
1111 +- plusieurs numéros
1112 +- plusieurs e-mails
1113 +- angle fort
1114 +- ombre
1115 +- OCR vide
1116 +- texte désordonné
1117 +- sortie Luciole partielle
1118 +- appareil modeste avec CPU limité
1119 +- modèles OCR ou données de langue non installés
1120 +
1121 +**Fichiers à mettre à jour**
1122 +
1123 +- `README.md`
1124 +- `docs/plans/ocr-carte-visite-vcf.md` (résultats benchmark)
1125 +- tests ciblés Android/Kotlin autour des nouveaux parseurs, intents et écrans
1126 +
1127 +## Ordre d'implémentation recommandé
1128 +
1129 +Séquence complète, sans report de périmètre :
1130 +
1131 +1. Story 0 — cadrage
1132 +2. Story 1 — contrat `ContactCard` + VCF
1133 +3. Story 2 — scan + OCR local
1134 +4. Story 3 — structuration Luciole
1135 +5. Story 4 — écran scan dédié
1136 +6. Story 6 — brouillon éditable
1137 +7. Story 7 — Contacts Android + export VCF
1138 +8. Story 5 — déclenchement depuis le chat
1139 +9. Story 8 — benchmark, robustesse, documentation
1140 +
1141 +## Périmètre produit complet
1142 +
1143 +La fonctionnalité n'est **pas livrable** tant que tous les éléments suivants ne sont pas en place :
1144 +
1145 +- scan documentaire avec correction géométrique réelle
1146 +- OCR local Tesseract opérationnel
1147 +- structuration `ContactCard` via Luciole-1B
1148 +- brouillon éditable avec texte OCR visible
1149 +- préremplissage Contacts Android
1150 +- export `.vcf` partageable
1151 +- action chat `scanner_carte`
1152 +- corpus de test + benchmark documenté
1153 +- non-régression sur les 11 actions existantes
1154 +
1155 +Parcours utilisateur cible :
1156 +
1157 +`je scanne (ou je dis « scanner une carte ») → l'app corrige l'image → OCR local → Luciole structure → je corrige → j'insère le contact ou j'exporte le VCF`
1158 +
1159 +## Problème qualité OCR et correction géométrique
1160 +
1161 +### Pourquoi l'angle fausse l'OCR
1162 +
1163 +Sur une carte de visite photographiée en main, l'échec OCR vient rarement du seul moteur de reconnaissance de caractères. Les causes dominantes sont :
1164 +
1165 +1. **perspective** : la carte n'est pas vue de face, les lignes de texte ne sont plus horizontales dans l'image
1166 +2. **rotation** : la photo est prise portrait/paysage sans que le texte soit remis à l'endroit
1167 +3. **recadrage insuffisant** : le fond, la main ou la table polluent la zone analysée
1168 +4. **qualité visuelle** : flou, ombre, reflet, faible contraste, police fine
1169 +
1170 +Conséquence pour `Luciole` : **il ne faut pas envoyer la photo brute directement à l'OCR**. Il faut d'abord produire une image « documentifiée » : redressée, recadrée, orientée correctement, puis seulement extraire le texte.
1171 +
1172 +### Pipeline qualité cible en deux étages
1173 +
1174 +```text
1175 +Étape A — Scan document (géométrie + nettoyage image)
1176 + capture -> détection bords -> correction perspective -> crop -> rotation -> amélioration visuelle
1177 +
1178 +Étape B — OCR texte (reconnaissance caractères)
1179 + image corrigée -> OCR local -> texte brut + blocs/lignes + confiance
1180 +
1181 +Étape C — Structuration sémantique
1182 + texte brut -> Luciole-1B -> ContactCard JSON
1183 +
1184 +Étape D — Validation humaine
1185 + brouillon éditable -> Contacts Android / export VCF
1186 +```
1187 +
1188 +### Exigences qualité minimales
1189 +
1190 +| Exigence | Cible |
1191 +|---|---|
1192 +| Carte inclinée jusqu'à ~30-45° | lecture exploitable après correction |
1193 +| Photo légèrement floue | dégradation acceptable, pas d'échec total |
1194 +| Ombre partielle | texte principal toujours lisible |
1195 +| Plusieurs numéros / e-mails | tous extraits ou au moins le principal |
1196 +| Carte bilingue FR/EN | OCR latin correct |
1197 +| Temps total utilisateur | viser < 8-12 s après capture sur appareil récent |
1198 +
1199 +### Ce que Luciole ne doit pas faire
1200 +
1201 +- corriger la perspective image
1202 +- détecter les bords du document
1203 +- pivoter l'image
1204 +- faire l'OCR caractère par caractère
1205 +
1206 +Luciole intervient **après** l'OCR, pour interpréter le texte : distinguer nom, poste, société, téléphone, e-mail, site, adresse.
1207 +
1208 +## Recherche technique des solutions mobiles
1209 +
1210 +Cette section synthétise une recherche web ciblée sur les solutions Android capables de gérer **angle, recadrage, rotation et deskew** avant OCR, en restant compatibles avec une app **100 % locale** comme `Luciole`.
1211 +
1212 +### Critères d'évaluation retenus
1213 +
1214 +| Critère | Poids | Description |
1215 +|---|---|---|
1216 +| Correction perspective / deskew | élevé | indispensable pour cartes prises en main |
1217 +| Rotation / horizontalité auto | élevé | texte remis à l'endroit avant OCR |
1218 +| OCR on-device | élevé | cohérence avec la promesse souveraineté du projet |
1219 +| Intégration Android/Kotlin/Compose | élevé | fit avec le dépôt actuel |
1220 +| Complexité d'intégration | moyen | temps jusqu'au premier prototype |
1221 +| Coût / licence | moyen | acceptable pour une démo vs produit |
1222 +| Dépendance Play Services | exclue | hors cible |
1223 +| Personnalisation UX | faible | requis pour le produit final |
1224 +
1225 +### Option 1 - ML Kit (Google, hors cible)
1226 +
1227 +**Références**
1228 +
1229 +- [ML Kit Document Scanner](https://developers.google.com/ml-kit/vision/doc-scanner)
1230 +- [ML Kit Document Scanner Android](https://developers.google.com/ml-kit/vision/doc-scanner/android)
1231 +- [ML Kit Text Recognition v2 Android](https://developers.google.com/ml-kit/vision/text-recognition/v2/android)
1232 +- [ML Kit release notes](https://developers.google.com/ml-kit/release-notes)
1233 +
1234 +**Pile recommandée**
1235 +
1236 +```kotlin
1237 +// Scan / correction géométrique
1238 +implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0")
1239 +
1240 +// OCR texte (script latin, on-device)
1241 +implementation("com.google.mlkit:text-recognition:16.0.1")
1242 +// ou variante Play Services :
1243 +// implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.1")
1244 +```
1245 +
1246 +**Capacités documentées**
1247 +
1248 +| Fonctionnalité | Support |
1249 +|---|---|
1250 +| Détection automatique du document | oui |
1251 +| Capture automatique | oui |
1252 +| Edge detection | oui |
1253 +| Correction de perspective | oui |
1254 +| Rotation automatique | oui |
1255 +| Crop manuel utilisateur | oui |
1256 +| Filtres / nettoyage image | oui (`SCANNER_MODE_FULL`) |
1257 +| Import galerie | oui (configurable) |
1258 +| Traitement on-device | oui |
1259 +| Permission caméra dans l'app | non requise pour le scanner ML Kit |
1260 +
1261 +**Contraintes techniques**
1262 +
1263 +- `minSdk` Document Scanner : API 21+ (le projet est à 31, OK)
1264 +- RAM minimale : **1,7 Go** — sinon `MlKitException UNSUPPORTED`
1265 +- modèles et UI téléchargés via **Google Play Services** au premier usage
1266 +- impact APK annoncé : ~300 Ko pour le scanner
1267 +- Text Recognition v2 : recommandation Google d'au moins **16x16 px par caractère**
1268 +- Text Recognition v2 : retourne blocs, lignes, éléments, **score de confiance**, orientation
1269 +
1270 +**Modes scanner utiles**
1271 +
1272 +| Mode | Usage |
1273 +|---|---|
1274 +| `SCANNER_MODE_BASE` | crop, rotation, réordonnancement |
1275 +| `SCANNER_MODE_BASE_WITH_FILTER` | + filtres image |
1276 +| `SCANNER_MODE_FULL` | + nettoyage ML (taches, ombres, doigts) — **recommandé** |
1277 +
1278 +**Configuration cible pour Luciole**
1279 +
1280 +```kotlin
1281 +val options = GmsDocumentScannerOptions.Builder()
1282 + .setGalleryImportAllowed(true)
1283 + .setPageLimit(1)
1284 + .setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
1285 + .setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
1286 + .build()
1287 +```
1288 +
1289 +Puis, sur le `Uri` JPEG retourné :
1290 +
1291 +```kotlin
1292 +val image = InputImage.fromFilePath(context, uri)
1293 +val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
1294 +recognizer.process(image)
1295 +```
1296 +
1297 +**Forces**
1298 +
1299 +- bon ratio simplicité / qualité pour une intégration Android open source
1300 +- couvre exactement le besoin « angle + crop + rotation » sans réécrire CameraX/OpenCV
1301 +- s'intègre naturellement dans `MainActivity` via Activity Result API
1302 +- OCR et scan restent locaux
1303 +
1304 +**Faiblesses**
1305 +
1306 +- dépendance Google Play Services
1307 +- UI scanner fournie par Google, peu personnalisable
1308 +- qualité OCR perfectible sur polices très stylisées ou cartes très graphiques
1309 +- premier lancement peut nécessiter téléchargement des modèles
1310 +
1311 +**Verdict**
1312 +
1313 +Solution techniquement intéressante, mais hors cible pour ce plan.
1314 +
1315 +### Option 2 - Scanbot SDK (hors cible)
1316 +
1317 +**Références**
1318 +
1319 +- [Android Document Scanner SDK](https://scanbot.io/developer/android-document-scanner-sdk/)
1320 +- [Document Enhancer](https://docs.scanbot.io/android/document-scanner-sdk/document-enhancer/introduction/)
1321 +- [OCR module](https://docs.scanbot.io/android/data-capture-modules/ocr/introduction/)
1322 +- [Pricing](https://docs.scanbot.io/faq/pricing-and-licensing-model/)
1323 +
1324 +**Capacités documentées**
1325 +
1326 +| Fonctionnalité | Support |
1327 +|---|---|
1328 +| Auto-capture | oui |
1329 +| Edge detection | oui |
1330 +| Auto-crop | oui |
1331 +| Document straightening | oui |
1332 +| Correction perspective | oui |
1333 +| Contrôle qualité scan | oui |
1334 +| OCR offline | oui (`OcrEngine`) |
1335 +| 100 % on-device | oui |
1336 +
1337 +**Modèle de licence**
1338 +
1339 +- **forfait annuel fixe**, sans facturation au scan ni à l'utilisateur
1340 +- prix dépend du **nombre d'apps** et du **pack de fonctionnalités**
1341 +- essai gratuit via licence trial
1342 +
1343 +**Forces**
1344 +
1345 +- qualité documentaire très élevée
1346 +- excellent sur photos imparfaites, angles, ombres
1347 +- RTU UI mature
1348 +- bon candidat si la qualité scan devient un argument produit fort
1349 +
1350 +**Faiblesses**
1351 +
1352 +- SDK commercial, devis obligatoire
1353 +- intégration plus lourde
1354 +- dépendance fournisseur
1355 +
1356 +**Verdict**
1357 +
1358 +Solution premium documentée, mais hors cible de décision ici.
1359 +
1360 +### Option 3 - Dynamsoft Capture Vision (hors cible)
1361 +
1362 +**Références**
1363 +
1364 +- [Capture Vision overview](https://www.dynamsoft.com/capture-vision/overview/)
1365 +- [Android document scanner tutorial](https://www.dynamsoft.com/codepool/build-android-document-scanner-app-dynamsoft-capture-vision.html)
1366 +- [Document Normalizer Android guide](https://www.dynamsoft.com/document-normalizer/docs/mobile/programming/android/user-guide.html)
1367 +
1368 +**Capacités documentées**
1369 +
1370 +| Fonctionnalité | Support |
1371 +|---|---|
1372 +| Détection bords temps réel | oui |
1373 +| Normalisation document | oui |
1374 +| Correction perspective | oui |
1375 +| Deskew | oui |
1376 +| Stabilisation multi-frame | oui (`MultiFrameResultCrossFilter`) |
1377 +| Édition manuelle des coins | oui |
1378 +| Traitement on-device | oui |
1379 +
1380 +**Forces**
1381 +
1382 +- contrôle très fin du pipeline de capture
1383 +- robuste sur documents pris en angle
1384 +- bon pour workflows documentaires ambitieux
1385 +- auto-capture stabilisée par comparaison de quads sur plusieurs frames
1386 +
1387 +**Faiblesses**
1388 +
1389 +- commercial
1390 +- courbe d'intégration plus technique
1391 +- surdimensionné pour une simple feature carte de visite
1392 +
1393 +**Verdict**
1394 +
1395 +**Très bon choix pro**, surtout si l'on prévoit d'autres flux documentaires dans `Luciole`.
1396 +
1397 +### Option 4 - OpenCV + Tesseract (open source)
1398 +
1399 +**Références**
1400 +
1401 +- [Automatic Contact Importer from Business Cards (Stanford)](https://stacks.stanford.edu/file/druid:np318ty6250/Sharma_Fujii_Automatic_Contact_Importer.pdf) — pipeline historique OpenCV + Tesseract sur Android
1402 +- [OpenCV deskew tutorial (Dynamsoft)](https://www.dynamsoft.com/codepool/deskew-scanned-document.html)
1403 +
1404 +**Capacités**
1405 +
1406 +| Fonctionnalité | Support |
1407 +|---|---|
1408 +| Détection contour | oui, mais à coder |
1409 +| Homographie / perspective | oui, mais à coder |
1410 +| Deskew | oui, mais fragile |
1411 +| OCR | oui via Tesseract |
1412 +| 100 % open source | oui |
1413 +
1414 +**Forces**
1415 +
1416 +- aucune dépendance commerciale
1417 +- contrôle total
1418 +- bon pour recherche / prototypage académique
1419 +
1420 +**Faiblesses**
1421 +
1422 +- beaucoup de code custom (CameraX + OpenCV + JNI)
1423 +- qualité et maintenance élevées
1424 +- deskew/rotation moins robustes que les SDK documentaires modernes
1425 +- temps d'ingénierie important
1426 +
1427 +**Verdict**
1428 +
1429 +**Non recommandé pour le premier incrément** de `Luciole`, sauf contrainte licence stricte.
1430 +
1431 +## Matrice de décision synthétique
1432 +
1433 +| Solution | Perspective / deskew | Rotation auto | OCR local | Intégration | Coût | Fit Luciole |
1434 +|---|---:|---:|---:|---:|---:|---:|
1435 +| **OpenCV + Tesseract4Android** | bon | oui | oui | élevée | gratuit | **excellent** |
1436 +| **OpenCV + PaddleOCR** | bon | oui | oui | élevée | gratuit | très bon |
1437 +| **AndroidDocumentScanner + Tesseract4Android** | bon | oui | oui | moyenne | gratuit | très bon |
1438 +| **OpenNoteScanner dérivé + OCR** | bon | oui | oui | moyenne | gratuit | moyen |
1439 +
1440 +## Stratégie qualité OCR pour Luciole
1441 +
1442 +### Principe directeur
1443 +
1444 +La qualité finale ne dépend pas d'un seul composant. Elle résulte de la chaîne complète :
1445 +
1446 +`bonne capture -> image corrigée -> OCR fiable -> structuration Luciole -> validation humaine`
1447 +
1448 +### Règles produit à implémenter
1449 +
1450 +1. **Toujours passer par un scan document** avant OCR, jamais OCR sur photo brute si évitable.
1451 +2. **Limiter à 1 page / 1 carte** par scan (`pageLimit = 1`).
1452 +3. **Autoriser l'import galerie**, mais appliquer le même pipeline de correction si possible.
1453 +4. **Afficher le texte OCR brut** à l'utilisateur pour diagnostic.
1454 +5. **Ne jamais créer un contact automatiquement** sans validation.
1455 +6. **Compléter Luciole par des extracteurs déterministes** pour téléphone, e-mail, URL — comme le projet le fait déjà pour les numéros dans `Extraction.kt`.
1456 +
1457 +### Post-traitement OCR recommandé (Kotlin)
1458 +
1459 +Après OCR, avant Luciole :
1460 +
1461 +```kotlin
1462 +data class OcrBlock(
1463 + val text: String,
1464 + val confidence: Float?,
1465 + val boundingBox: Rect?,
1466 +)
1467 +
1468 +data class OcrResult(
1469 + val rawText: String,
1470 + val lines: List<String>,
1471 + val blocks: List<OcrBlock>,
1472 +)
1473 +```
1474 +
1475 +Règles de nettoyage :
1476 +
1477 +- fusionner les lignes dans l'ordre vertical (top -> bottom)
1478 +- supprimer les lignes vides
1479 +- dédupliquer les lignes identiques
1480 +- extraire par regex :
1481 + - e-mails : pattern standard
1482 + - téléphones : réutiliser la logique de `Extraction.extractPhone`
1483 + - sites web : `https?://` ou domaines
1484 +- conserver le texte brut intégral pour Luciole
1485 +
1486 +### Complément déterministe + Luciole
1487 +
1488 +Inspiré du pattern existant `cerveau route -> mains revalident` :
1489 +
1490 +| Champ | Source primaire | Source secondaire |
1491 +|---|---|---|
1492 +| `phones` | regex / heuristiques | Luciole |
1493 +| `emails` | regex | Luciole |
1494 +| `website` | regex URL | Luciole |
1495 +| `full_name` | Luciole | heuristique première ligne |
1496 +| `company` | Luciole | heuristique ligne sans @ ni chiffres |
1497 +| `job_title` | Luciole | — |
1498 +| `address` | Luciole | lignes multi-mots sans @ |
1499 +
1500 +Cela réduit la dépendance à la seule intelligence du SLM 1B.
1501 +
1502 +## Interfaces techniques cibles
1503 +
1504 +Pour permettre le remplacement de fournisseur sans casser le reste du pipeline :
1505 +
1506 +```kotlin
1507 +// android/.../scan/ScanEngine.kt
1508 +interface ScanEngine {
1509 + suspend fun scanDocument(): ScanResult
1510 +}
1511 +
1512 +data class ScanResult(
1513 + val imageUri: Uri,
1514 + val pageCount: Int = 1,
1515 +)
1516 +
1517 +// android/.../ocr/OcrEngine.kt
1518 +interface OcrEngine {
1519 + suspend fun recognize(imageUri: Uri): OcrResult
1520 +}
1521 +
1522 +// android/.../cerveau/Cerveau.kt — extension
1523 +interface Cerveau {
1524 + suspend fun suggest(phrase: String): Action
1525 + suspend fun extractContact(rawText: String): ContactCard
1526 +}
1527 +```
1528 +
1529 +Implémentations prévues :
1530 +
1531 +| Interface | Implémentation retenue | Implémentation alternative |
1532 +|---|---|---|
1533 +| `ScanEngine` | `OpenCvScanEngine` | `AndroidDocumentScannerAdapter`, custom |
1534 +| `OcrEngine` | `TesseractOcrEngine` | `PaddleOcrEngine`, custom |
1535 +| `Cerveau.extractContact` | `CerveauServeur` + `ContactPrompt` | futur `CerveauEmbarqué` |
1536 +
1537 +## Comparatif technique concret des briques OCR mobiles
1538 +
1539 +Le besoin réel ne se limite pas à l'OCR. Il inclut aussi :
1540 +
1541 +- détection des bords
1542 +- correction de perspective
1543 +- recadrage
1544 +- rotation automatique
1545 +- détection de l'horizontalité
1546 +- deskew avant OCR
1547 +
1548 +### Option 1 - ML Kit (hors cible)
1549 +
1550 +**Pile recommandée**
1551 +
1552 +- `ML Kit Document Scanner`
1553 +- `ML Kit Text Recognition`
1554 +
1555 +**Ce que la solution couvre bien**
1556 +
1557 +- détection automatique du document
1558 +- recadrage automatique
1559 +- correction de perspective
1560 +- rotation automatique
1561 +- amélioration visuelle
1562 +- suppression d'ombres et artefacts selon le mode choisi
1563 +- OCR local sur l'image déjà corrigée
1564 +
1565 +**Avantages**
1566 +
1567 +- très bon fit Android natif
1568 +- intégration simple
1569 +- faible quantité de code
1570 +- on-device
1571 +- cohérent avec l'architecture actuelle de `Luciole`
1572 +
1573 +**Inconvénients**
1574 +
1575 +- moins de contrôle fin qu'un SDK spécialisé
1576 +- dépendance à Google Play Services
1577 +- qualité parfois un peu moins maîtrisable qu'un SDK premium
1578 +
1579 +**Verdict**
1580 +
1581 +Solution simple pour Android avec Google, mais hors cible pour ce plan.
1582 +
1583 +### Option 2 - Scanbot SDK (hors cible)
1584 +
1585 +**Ce que la solution couvre bien**
1586 +
1587 +- auto-capture
1588 +- edge detection
1589 +- auto-crop
1590 +- document straightening
1591 +- correction de perspective
1592 +- OCR offline
1593 +- meilleure maîtrise qualité scan
1594 +
1595 +**Avantages**
1596 +
1597 +- excellente qualité documentaire
1598 +- très robuste sur documents photographiés en conditions imparfaites
1599 +- UX scanner mature
1600 +- offline
1601 +
1602 +**Inconvénients**
1603 +
1604 +- SDK commercial
1605 +- coût de licence
1606 +- intégration plus lourde
1607 +
1608 +**Verdict**
1609 +
1610 +Solution premium utile comme benchmark externe, mais hors cible ici.
1611 +
1612 +### Option 3 - Dynamsoft (hors cible)
1613 +
1614 +**Ce que la solution couvre bien**
1615 +
1616 +- détection temps réel des bords
1617 +- normalisation document
1618 +- correction de perspective
1619 +- deskew
1620 +- stabilisation multi-frame
1621 +- contrôle avancé du pipeline
1622 +
1623 +**Avantages**
1624 +
1625 +- très bon niveau technique
1626 +- excellent contrôle de la capture
1627 +- robuste sur scans pris en angle
1628 +- bon pour des workflows documentaires plus ambitieux
1629 +
1630 +**Inconvénients**
1631 +
1632 +- commercial
1633 +- intégration plus technique
1634 +- plus lourd qu'une intégration minimale
1635 +
1636 +**Verdict**
1637 +
1638 +Très bon choix pro si l'objectif va au-delà d'une simple feature OCR carte de visite.
1639 +
1640 +## Recommandation finale
1641 +
1642 +La décision d'implémentation est fixée dans [Choix techniques retenus](#choix-techniques-retenus).
1643 +
1644 +**Résumé en une ligne :** `CameraX` + `OpenCvScanEngine` + `Tesseract4Android` + `Luciole-1B` + validation utilisateur.
1645 +
1646 +### Abstractions à introduire dès le départ
1647 +
1648 +- `ScanEngine` — correction géométrique interchangeable
1649 +- `OcrEngine` — OCR interchangeable (`Tesseract` aujourd'hui, `PaddleOCR` demain si besoin)
1650 +- `ContactCard` — contrat métier séparé du `JSON d'action`
1651 +
1652 +Pipeline stable :
1653 +
1654 +`scan/correction -> OCR -> post-traitement -> Luciole -> ContactCard -> Contacts/VCF`
1655 +
1656 +### Quand changer de brique
1657 +
1658 +| Situation | Action |
1659 +|---|---|
1660 +| Cible générique indépendante | **OpenCV + Tesseract4Android** (choix actuel) |
1661 +| Qualité OCR insuffisante malgré bonne géométrie | remplacer `TesseractOcrEngine` par `PaddleOcrEngine` |
1662 +| `OpenCvScanEngine` trop lent ou fragile | s'inspirer de **AndroidDocumentScanner** (MIT) |
1663 +| Échecs surtout sémantiques (nom/poste) | améliorer `ContactPrompt`, pas le scanner |
1664 +
1665 +## Risques à surveiller
1666 +
1667 +- cartes très stylisées
1668 +- polices fines ou fantaisie
1669 +- photos très inclinées (> 45°)
1670 +- ombres fortes et reflets
1671 +- appareils modestes avec CPU limité
1672 +- qualité variable selon les `traineddata` Tesseract choisis
1673 +- temps global `scan -> OCR -> structuration`
1674 +- qualité de la séparation `nom / poste / société` par `Luciole-1B`
1675 +- confusion chiffres / lettres sur certaines polices (limitation connue OCR généraliste)
1676 +
1677 +## Sources et références
1678 +
1679 +| Ressource | URL |
1680 +|---|---|
1681 +| Tesseract4Android | https://github.com/adaptech-cz/Tesseract4Android/ |
1682 +| Releases Tesseract4Android | https://github.com/adaptech-cz/Tesseract4Android/releases |
1683 +| PaddleOCR Android deployment | https://www.paddleocr.ai/latest/en/version3.x/inference_deployment/cross_platform/android_deployment.html |
1684 +| PaddleOCR Android repo | https://github.com/PaddlePaddle/PaddleOCR/tree/main/deploy/ppocr-android |
1685 +| paddleocr4android | https://github.com/equationl/paddleocr4android |
1686 +| AndroidDocumentScanner | https://github.com/mayuce/AndroidDocumentScanner |
1687 +| OpenNoteScanner | https://github.com/allgood/OpenNoteScanner |
1688 +| OSS-DocumentScanner | https://github.com/ossappscollective/OSS-DocumentScanner/ |
1689 +| trudido-scanner | https://github.com/dominikmuellr/trudido-scanner |
1690 +| OpenCV deskew (référence technique) | https://www.dynamsoft.com/codepool/deskew-scanned-document.html |
1691 +| Business card OCR OpenCV+Tesseract (académique) | https://stacks.stanford.edu/file/druid:np318ty6250/Sharma_Fujii_Automatic_Contact_Importer.pdf |
1692 +
1693 +## Recherche technique des solutions mobiles sans Google Play
1694 +
1695 +### Nouveau cadrage
1696 +
1697 +`Luciole` doit fonctionner sur des appareils Android **sans Google Play Services**.
1698 +
1699 +Conséquence :
1700 +
1701 +- il n'y a **pas de variante Android standard** dans ce plan
1702 +- la **cible unique** repose sur des composants **open source** ou intégrables sans dépendance Play Services
1703 +
1704 +Dans ce cadrage, il faut séparer très explicitement :
1705 +
1706 +1. **scan documentaire / correction géométrique**
1707 +2. **OCR**
1708 +3. **structuration sémantique par Luciole**
1709 +
1710 +### Critères spécifiques "sans Google Play"
1711 +
1712 +| Critère | Importance | Commentaire |
1713 +|---|---|---|
1714 +| Fonctionne sans Play Services | critique | exigence principale |
1715 +| Open source ou source intégrable | critique | pour Android générique |
1716 +| Correction perspective / recadrage | critique | l'angle fausse l'OCR |
1717 +| Rotation / deskew | critique | horizontalité du texte |
1718 +| OCR offline | critique | cohérence avec la promesse locale |
1719 +| Intégration Kotlin/Android | élevée | fit avec le dépôt actuel |
1720 +| Maturité / maintenance | élevée | éviter une stack morte |
1721 +| Complexité d'intégration | moyenne | acceptable si gain réel |
1722 +| Licence compatible produit | élevée | attention GPL vs MIT/Apache |
1723 +
1724 +### Option A - OpenCV + Tesseract4Android
1725 +
1726 +**Références**
1727 +
1728 +- [Tesseract4Android](https://github.com/adaptech-cz/Tesseract4Android/)
1729 +- [Releases Tesseract4Android](https://github.com/adaptech-cz/Tesseract4Android/releases)
1730 +
1731 +**Architecture**
1732 +
1733 +```text
1734 +CameraX -> photo
1735 +-> OpenCV (détection quadrilatère, crop, homographie, deskew, rotation)
1736 +-> bitmap corrigé
1737 +-> Tesseract4Android
1738 +-> texte OCR
1739 +-> Luciole-1B
1740 +-> ContactCard
1741 +```
1742 +
1743 +**Ce que couvre la stack**
1744 +
1745 +| Fonctionnalité | Support |
1746 +|---|---|
1747 +| OCR offline | oui |
1748 +| Sans Google Play | oui |
1749 +| Open source | oui |
1750 +| Tesseract moderne Android | oui |
1751 +| Perspective correction | oui, mais à développer avec OpenCV |
1752 +| Deskew / rotation | oui, mais à développer avec OpenCV |
1753 +
1754 +**Points techniques utiles**
1755 +
1756 +- `Tesseract4Android` est sous **Apache 2.0**
1757 +- wrapper JNI moderne autour de **Tesseract 5.5.1**
1758 +- variantes standard et `openmp`
1759 +- intégration Android actuelle via dépendance :
1760 +
1761 +```gradle
1762 +implementation("cz.adaptech.tesseract4android:tesseract4android:4.9.0")
1763 +// ou
1764 +implementation("cz.adaptech.tesseract4android:tesseract4android-openmp:4.9.0")
1765 +```
1766 +
1767 +**Avantages**
1768 +
1769 +- véritable stack sans Google Play
1770 +- OCR open source mature
1771 +- bon contrôle sur la chaîne complète
1772 +- licence globalement favorable côté OCR
1773 +
1774 +**Inconvénients**
1775 +
1776 +- la partie la plus difficile reste à votre charge : détection de la carte, correction de perspective, deskew
1777 +- Tesseract seul n'est pas la meilleure solution sur cartes très graphiques ou polices fines
1778 +- beaucoup plus d'ingénierie qu'une solution managée
1779 +
1780 +**Verdict**
1781 +
1782 +C'est la **base open source la plus réaliste** si l'objectif est Android générique sans Google Play.
1783 +
1784 +### Option B - PaddleOCR Android
1785 +
1786 +**Références**
1787 +
1788 +- [PaddleOCR Android deployment](https://www.paddleocr.ai/latest/en/version3.x/inference_deployment/cross_platform/android_deployment.html)
1789 +- [PaddleOCR Android repo](https://github.com/PaddlePaddle/PaddleOCR/tree/main/deploy/ppocr-android)
1790 +- [paddleocr4android](https://github.com/equationl/paddleocr4android)
1791 +
1792 +**Architecture**
1793 +
1794 +```text
1795 +CameraX / import image
1796 +-> correction géométrique (OpenCV ou composant scanner dédié)
1797 +-> PaddleOCR Android SDK
1798 +-> texte OCR
1799 +-> Luciole-1B
1800 +-> ContactCard
1801 +```
1802 +
1803 +**Ce que couvre la stack**
1804 +
1805 +| Fonctionnalité | Support |
1806 +|---|---|
1807 +| OCR offline | oui |
1808 +| Sans Google Play | oui |
1809 +| Open source | oui |
1810 +| Détection + reconnaissance end-to-end | oui |
1811 +| Perspective correction | partiellement, souvent à compléter |
1812 +| Deskew / scan doc | pas la brique principale |
1813 +
1814 +**Points techniques utiles**
1815 +
1816 +- démo Android officielle `PP-OCRv6`
1817 +- architecture `SDK + Demo`
1818 +- déploiement Android basé sur **ONNX Runtime**
1819 +- `minSdk 26`
1820 +- dépendances supplémentaires typiques :
1821 +
1822 +```gradle
1823 +implementation(files("libs/ppocr-sdk-release.aar"))
1824 +implementation("com.microsoft.onnxruntime:onnxruntime-android:1.21.1")
1825 +implementation("com.quickbirdstudios:opencv:4.5.3")
1826 +```
1827 +
1828 +**Avantages**
1829 +
1830 +- OCR open source plus moderne que Tesseract dans beaucoup de cas
1831 +- pipeline OCR mobile plus ambitieux
1832 +- pas de dépendance Play Services
1833 +
1834 +**Inconvénients**
1835 +
1836 +- intégration plus lourde
1837 +- tailles de modèles et dépendances plus conséquentes
1838 +- pour la carte de visite, il faut souvent tout de même une vraie brique de scan géométrique en amont
1839 +- `minSdk 26`, alors que le projet actuel supporte `31`, donc compatible ici, mais moins universel
1840 +
1841 +**Verdict**
1842 +
1843 +Très intéressant si la **qualité OCR pure** devient prioritaire, mais pas suffisant seul pour régler le problème d'angle. Il faut souvent l'associer à OpenCV.
1844 +
1845 +### Option C - Bibliothèques / projets open source de scan documentaire Android
1846 +
1847 +**Références**
1848 +
1849 +- [OpenNoteScanner](https://github.com/allgood/OpenNoteScanner)
1850 +- [AndroidDocumentScanner](https://github.com/mayuce/AndroidDocumentScanner)
1851 +- [OSS-DocumentScanner](https://github.com/ossappscollective/OSS-DocumentScanner/)
1852 +- [trudido-scanner](https://github.com/dominikmuellr/trudido-scanner)
1853 +
1854 +Ces projets sont utiles non seulement comme bibliothèque potentielle, mais aussi comme **référence d'implémentation** pour la détection des bords, le crop manuel et la correction de perspective.
1855 +
1856 +#### C1 - OpenNoteScanner
1857 +
1858 +**Caractéristiques**
1859 +
1860 +- projet Android open source historique
1861 +- scan documentaire avec OpenCV
1862 +- détection de bords
1863 +- correction de perspective
1864 +- export image/PDF
1865 +- disponible aussi via F-Droid
1866 +
1867 +**Attention**
1868 +
1869 +- historique de dépendance à `OpenCV Manager`
1870 +- licence **GPLv3**
1871 +
1872 +**Verdict**
1873 +
1874 +Très utile comme **référence technique**, mais moins idéale comme dépendance directe dans une app si la contrainte GPL est problématique.
1875 +
1876 +#### C2 - AndroidDocumentScanner
1877 +
1878 +**Caractéristiques**
1879 +
1880 +- bibliothèque Android basée sur OpenCV
1881 +- licence **MIT**
1882 +- objectif explicite : scanner des documents façon CamScanner
1883 +
1884 +**Verdict**
1885 +
1886 +Candidat plus simple que OpenNoteScanner pour réutiliser une brique de scan open source avec licence plus souple.
1887 +
1888 +#### C3 - trudido-scanner
1889 +
1890 +**Caractéristiques**
1891 +
1892 +- bibliothèque Android récente
1893 +- `CameraX + OpenCV + C++17/JNI`
1894 +- sans Play Services
1895 +- détection de coins de document
1896 +- crop interactif
1897 +- module AAR réutilisable
1898 +
1899 +**Attention**
1900 +
1901 +- projet très récent
1902 +- peu de recul / peu d'adoption
1903 +- licence **GPLv3**
1904 +
1905 +**Verdict**
1906 +
1907 +Intéressant comme inspiration moderne, mais maturité encore faible.
1908 +
1909 +#### C4 - OSS-DocumentScanner
1910 +
1911 +**Caractéristiques**
1912 +
1913 +- projet actif
1914 +- licence **MIT**
1915 +- utilise OpenCV et Tesseract
1916 +- orienté application plus que petite bibliothèque Android native pure
1917 +
1918 +**Verdict**
1919 +
1920 +Très utile comme base d'étude pour le pipeline document + OCR offline, mais probablement trop gros pour être intégré tel quel.
1921 +
1922 +### Matrice comparative "sans Google Play"
1923 +
1924 +| Solution | Sans Play | Open source | Scan géométrique | OCR | Licence | Complexité | Recommandation |
1925 +|---|---:|---:|---:|---:|---:|---:|---|
1926 +| **OpenCV + Tesseract4Android** | oui | oui | à coder | oui | Apache 2.0 + dépendances | élevée | **base recommandée** |
1927 +| **OpenCV + PaddleOCR** | oui | oui | à coder | oui | open source | élevée | très bon si priorité OCR |
1928 +| **AndroidDocumentScanner + Tesseract4Android** | oui | oui | oui | oui | MIT + Apache | moyenne | **très bon compromis** |
1929 +| **OpenNoteScanner + Tesseract** | oui | oui | oui | possible | GPLv3 | moyenne | bon pour étude, moins bon pour intégration produit |
1930 +| **trudido-scanner + Tesseract/PaddleOCR** | oui | oui | oui | à ajouter | GPLv3 | moyenne/élevée | prometteur mais immature |
1931 +
1932 +## Décision révisée pour Android générique
1933 +
1934 +Si la contrainte devient :
1935 +
1936 +> « fonctionner sans Google Play, sur Android générique, avec des composants open source »
1937 +
1938 +alors la recommandation change.
1939 +
1940 +### Nouvelle recommandation cible
1941 +
1942 +#### Recommandation 1 - compromis le plus réaliste
1943 +
1944 +```text
1945 +CameraX
1946 +-> ScanEngine basé sur OpenCV / bibliothèque open source de scan
1947 +-> OcrEngine basé sur Tesseract4Android
1948 +-> OcrPostProcessor
1949 +-> Luciole-1B
1950 +-> ContactCard
1951 +```
1952 +
1953 +Option concrète à étudier en priorité :
1954 +
1955 +- `AndroidDocumentScanner` ou implémentation OpenCV maison pour le scan
1956 +- `Tesseract4Android` pour l'OCR
1957 +
1958 +#### Recommandation 2 - si la qualité OCR brute est prioritaire
1959 +
1960 +```text
1961 +CameraX
1962 +-> OpenCV / scan doc
1963 +-> PaddleOCR Android
1964 +-> OcrPostProcessor
1965 +-> Luciole-1B
1966 +-> ContactCard
1967 +```
1968 +
1969 +### Pourquoi cette révision
1970 +
1971 +- les solutions dépendantes de Google Play ne correspondent pas à la cible
1972 +- le problème principal reste la **géométrie de l'image**
1973 +- il faut une stack qui garde la maîtrise sur :
1974 + - perspective
1975 + - rotation
1976 + - crop
1977 + - deskew
1978 + - OCR offline
1979 +
1980 +## Impact sur le plan d'implémentation
1981 +
1982 +### Story 2 révisée pour la cible unique Android générique
1983 +
1984 +La story 2 doit prévoir une **seule pile fournisseur** :
1985 +
1986 +- `OpenCvScanEngine`
1987 +- `TesseractOcrEngine`
1988 +
1989 +avec possibilité ultérieure de remplacer uniquement `TesseractOcrEngine` par `PaddleOcrEngine` si les mesures le justifient.
1990 +
1991 +### Interfaces à figer impérativement
1992 +
1993 +```kotlin
1994 +interface ScanEngine {
1995 + suspend fun scanDocument(): ScanResult
1996 +}
1997 +
1998 +interface OcrEngine {
1999 + suspend fun recognize(imageUri: Uri): OcrResult
2000 +}
2001 +```
2002 +
2003 +C'est ce découplage qui permettra de garder le reste du plan inchangé :
2004 +
2005 +`scan -> OCR -> Luciole -> ContactCard -> Contacts/VCF`
2006 +
2007 +## Conclusion
2008 +
2009 +Le plan réaliste pour `Luciole` est :
2010 +
2011 +1. **scanner et corriger l'image localement** (`CameraX` + `OpenCvScanEngine`)
2012 +2. **effectuer l'OCR localement** sur l'image corrigée (`Tesseract4Android`)
2013 +3. **post-traiter** le texte (regex + heuristiques, sur le modèle de `Extraction.kt`)
2014 +4. **utiliser `Luciole-1B`** pour structurer intelligemment le texte en `ContactCard`
2015 +5. **faire valider** le résultat par l'utilisateur avant insertion contact ou export VCF
2016 +
2017 +Ce choix respecte à la fois l'architecture actuelle du dépôt, les limites réelles du modèle, le besoin de qualité OCR sur photos en angle, et l'objectif produit demandé.
2018 +
2019 +La qualité ne viendra pas d'un seul composant : elle dépendra surtout de la **correction géométrique avant OCR**, puis de la **validation humaine** et du **complément déterministe** autour de Luciole.
A docs/plans/ocr_standalone_app_7aad0922.plan.md
+150 -0
@@ -0,0 +1,150 @@
1 +---
2 +name: Card2vcf OCR standalone
3 +overview: "App Android Card2vcf dans EBII_mobileVCF : scan carte → OCR local (7 langues) → ContactCard heuristique → Contacts + VCF, sans LLM. Code inspiré de luciole-mobile (AGPL)."
4 +todos:
5 + - id: s00-repo-plan
6 + content: Plan versionné + décisions figées Card2vcf
7 + status: completed
8 + - id: s01-scaffold
9 + content: Scaffold Android Compose minSdk 31, package fr.ebii.card2vcf
10 + status: pending
11 + - id: s02-deps
12 + content: "Gradle: CameraX, OpenCV, Tesseract JitPack, exif + fonts TTF + tessdata 7 langues"
13 + status: pending
14 + - id: s03-contact
15 + content: Migrer ContactCard, VCard, Insert, Share + tests
16 + status: pending
17 + - id: s04-scan
18 + content: Migrer ImageOrientation + OpenCvScanEngine + tests EXIF
19 + status: pending
20 + - id: s05-ocr
21 + content: Migrer Tesseract/Orientation/PostProcessor + tessdata 7 langues
22 + status: pending
23 + - id: s06-heuristics
24 + content: Heuristiques + DraftMerge(null) + tests dimo
25 + status: pending
26 + - id: s07-ui
27 + content: ScanCarteViewModel sans LLM + thème design.md + icon.png
28 + status: pending
29 + - id: s08-manifest
30 + content: Manifest CAMERA + FileProvider + LICENSE AGPL
31 + status: pending
32 + - id: s09-tests
33 + content: Unit + instrumented corpus dimo verts
34 + status: pending
35 + - id: s10-readme
36 + content: "README Card2vcf: usage, tessdata, limites sans LLM"
37 + status: pending
38 +isProject: true
39 +---
40 +
41 +# Plan Card2vcf (OCR standalone sans LLM)
42 +
43 +## Décisions figées
44 +
45 +- **Dépôt cible** : `/home/monsieurb/Documents/devel/EBII_mobileVCF` (pas un dépôt `luciole-ocr` séparé).
46 +- **Nom produit** : **Card2vcf**.
47 +- **Licence** : AGPL v3 (remote Git géré par le mainteneur).
48 +- **Package / applicationId** : `fr.ebii.card2vcf`.
49 +- **Périmètre produit** : Capture → EXIF → OpenCV → Tesseract → heuristiques `ContactCard` → brouillon éditable → `ACTION_INSERT` Contacts **et** export `.vcf`.
50 +- **Zéro LLM** : aucun `cerveau`, aucun OkHttp, aucune grammaire GBNF.
51 +- **minSdk** : 31 (Android 12+), compile/target 35. Contrainte produit hors-GMS / Android 14+ pour l’usage cible ; minSdk aligné luciole-mobile pour réutiliser le code validé.
52 +- **UI** : `design.md` (N&B éditorial, coins carrés, hairlines) + `icon.png` comme launcher.
53 +- **Polices** : TTF **embarquées** localement — Playfair Display (display), Lora (body serif), Manrope (sans UI).
54 +- **OCR langues** : `fra+deu+eng+spa+por+ita+pol` (7 `traineddata`).
55 +- **État UI** : conserver l’étape **Structuring** (« Analyse du contact… ») même sans LLM.
56 +- **Source code** : extraire / adapter depuis [luciole-mobile](file:///home/monsieurb/Documents/devel/luciole-mobile) (open source), packages renommés vers `fr.ebii.card2vcf`.
57 +
58 +## Workflow cible
59 +
60 +```mermaid
61 +flowchart LR
62 + cam[CameraX] --> exif[ImageOrientation]
63 + exif --> scan[OpenCvScanEngine]
64 + scan --> tess[TesseractOcrEngine]
65 + tess --> orient[OcrOrientation]
66 + orient --> post[OcrPostProcessor]
67 + post --> heur[ContactHeuristicParser]
68 + heur --> merge[ContactDraftMerge_null]
69 + merge --> draft[ContactDraftScreen]
70 + draft --> insert[ContactInsertIntent]
71 + draft --> vcf[VcfShare]
72 +```
73 +
74 +Remplacement Luciole :
75 +
76 +```kotlin
77 +val merged = ContactDraftMerge.merge(null, ocr)
78 +```
79 +
80 +## Arborescence cible
81 +
82 +```text
83 +EBII_mobileVCF/
84 +├── README.md
85 +├── LICENSE # AGPL v3
86 +├── AGENTS.md
87 +├── design.md
88 +├── icon.png
89 +├── docs/plans/…
90 +└── android/
91 + ├── settings.gradle.kts # + jitpack
92 + ├── build.gradle.kts
93 + └── app/
94 + ├── build.gradle.kts
95 + └── src/main/
96 + ├── AndroidManifest.xml
97 + ├── java/fr/ebii/card2vcf/
98 + │ ├── MainActivity.kt
99 + │ ├── contact/
100 + │ ├── scan/
101 + │ ├── ocr/
102 + │ └── ui/ (+ theme design.md)
103 + ├── assets/tesseract/tessdata/{fra,deu,eng,spa,por,ita,pol}.traineddata
104 + └── res/
105 + ├── font/{playfair_*,lora_*,manrope_*}
106 + ├── mipmap/ (depuis icon.png)
107 + ├── xml/file_paths.xml
108 + └── values/{strings,colors,themes}
109 +```
110 +
111 +## Exclusions strictes
112 +
113 +- Tout `cerveau/`, OkHttp, ZXing, GMS, ML Kit, kotlinx-serialization (sauf besoin réel).
114 +- `ContactCardJson.kt` exclu par défaut.
115 +
116 +## Gradle (app)
117 +
118 +```kotlin
119 +namespace = "fr.ebii.card2vcf"
120 +applicationId = "fr.ebii.card2vcf"
121 +minSdk = 31
122 +// CameraX 1.4.1, OpenCV 4.10.0, Tesseract4Android-openmp 4.9.0, exifinterface 1.3.7
123 +// PAS okhttp, PAS serialization obligatoire
124 +```
125 +
126 +## Tickets commit atomiques
127 +
128 +| # | Commit | Vérification |
129 +|---|---|---|
130 +| S00 | `docs(plan): Card2vcf OCR standalone sans LLM` | plan à jour |
131 +| S01 | `chore: scaffold Android Kotlin Compose Card2vcf` | assembleDebug |
132 +| S02 | `build: CameraX OpenCV Tesseract fonts tessdata` | resolve deps |
133 +| S03 | `feat(contact): ContactCard + VCard + Insert + Share` | tests contact |
134 +| S04 | `feat(scan): ImageOrientation + OpenCvScanEngine` | tests EXIF |
135 +| S05 | `feat(ocr): Tesseract 7 langues + PostProcessor` | OcrPostProcessingTest |
136 +| S06 | `feat(contact): HeuristicParser + DraftMerge(null)` | tests dimo |
137 +| S07 | `feat(ui): Scan + Draft thème design.md` | assemble + smoke |
138 +| S08 | `feat(android): Manifest CAMERA + FileProvider + icône` | install APK |
139 +| S09 | `test: corpus dimo unit + instrumented` | tests verts |
140 +| S10 | `docs(readme): Card2vcf usage, tessdata, limites` | README |
141 +
142 +## Critères d’acceptation
143 +
144 +1. Photo carte (EXIF 6 type dimo) → brouillon email + ≥1 téléphone sans crash.
145 +2. `ContactDraftMerge.merge(null, ocr)` remplit nom/poste/société/adresse sur fixture dimo.
146 +3. « Créer le contact » → Contacts prérempli ; « Exporter VCF » → `.vcf` partageable.
147 +4. Aucune dépendance cerveau / OkHttp / GMS / ML Kit.
148 +5. Tests unitaires verts ; instrumented dimo si appareil dispo.
149 +6. Offline total (pas de permission INTERNET).
150 +7. UI conforme design.md (N&B, coins 0, polices embarquées).
A docs/plans/sync_agenda_rdv_ressources_v2.md
+433 -0
@@ -0,0 +1,433 @@
1 +# Sync Agenda v2 — Plan d’implémentation (serveur puis mobile)
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. TDD. **Ne pas committer** sauf demande explicite de l’utilisateur.
4 +
5 +**Goal :** Exposer RDV + réservations/indispos en API/sync Projectiaon, puis pont Card2vcf Room ↔ Agenda Android multi-calendriers (Mes RDV + ressources sélectionnées), sans écran agenda in-app.
6 +
7 +**Architecture :** Étendre sync v1 (`tombstones`, `status`/`pull`, SyncOp, LWW). Serveur d’abord. Mobile : Paramètres crée/lie des calendriers système ; event ressource = réservation Active ; 409 → refresh + dialog annulation.
8 +
9 +**Tech Stack :** Rust/Axum (Projectiaon) ; Kotlin/Room/CalendarContract/OkHttp (Card2vcf).
10 +
11 +**Spec :** [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](../superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md)
12 +
13 +**Hors scope :** UI demande/réclamation mobile ; SyncAdapter ; calendriers cloud GMS ; écran agenda Card2vcf.
14 +
15 +---
16 +
17 +## Fichiers (carte)
18 +
19 +### Serveur (Projectiaon)
20 +
21 +| Fichier | Rôle |
22 +|---|---|
23 +| `src/modeles.rs` | `mis_a_jour_le` sur `RendezVous`, `Reservation`, `Indisponibilite` |
24 +| `src/handlers/rdv.rs` + chemins UI | Poser `mis_a_jour_le` à chaque mutation HTML |
25 +| `src/services/ressources.rs` | Idem résa/indispo ; mapper conflit → signal API 409 |
26 +| `src/handlers/api.rs` | Routes `/api/rdv`, `/api/ressources*`, OpenAPI |
27 +| `src/services/sync.rs` | Delta rdv/reservations/indisponibilites + tombstones + query `ressources` |
28 +| `src/services/sync.rs` `TypeTombstone` | Variantes `Rdv`, `Reservation`, `Indisponibilite` |
29 +| `tests/api_agenda_http.rs` | Tests HTTP Bearer CRUD + sync + 409 |
30 +| `docs/guide-administrateur.md` | Doc endpoints agenda mobile |
31 +
32 +### Mobile (EBII_mobileVCF)
33 +
34 +| Fichier | Rôle |
35 +|---|---|
36 +| `AndroidManifest.xml` | `READ_CALENDAR`, `WRITE_CALENDAR` |
37 +| `sync/CalendarBindingsStore.kt` | Prefs calendriers liés |
38 +| `sync/CalendarBridge.kt` | CRUD events / calendriers locaux |
39 +| `data/RdvEntity.kt` + Dao | Room RDV |
40 +| `data/ReservationEntity.kt` + Dao | Room résas |
41 +| `data/IndisponibiliteEntity.kt` + Dao | Room indispos lecture |
42 +| `CrmDatabase.kt` | version ↑ (3), entities |
43 +| `sync/SyncModels.kt` + `AilianceApi*` | DTOs + méthodes API |
44 +| `sync/SyncEngine.kt` | Agenda↔Room↔API, conflit 409 |
45 +| `ui/settings/*` | Section calendriers |
46 +| `ui/sync/SyncBanner.kt` + ViewModels | Bandeau « local en avance » + dialog conflit |
47 +| Tests unitaires | Bridge mapping, SyncEngine conflit, bindings |
48 +
49 +---
50 +
51 +# Partie A — Serveur
52 +
53 +## Task S1 — `mis_a_jour_le` sur RDV / Reservation / Indisponibilite
54 +
55 +**Files :**
56 +- Modify: `Projectiaon/src/modeles.rs` (`RendezVous`, `Reservation`, `Indisponibilite`)
57 +- Modify: handlers/services qui écrivent ces entités (`handlers/rdv.rs`, `services/rdv.rs`, `services/ressources.rs`, `handlers/ressources.rs`, `handlers/planning.rs` si besoin)
58 +- Test: étendre tests unitaires existants ressources/rdv ou nouveau test modèle
59 +
60 +- [ ] **Step 1 :** Ajouter aux 3 structs :
61 +
62 +```rust
63 +#[serde(default)]
64 +pub mis_a_jour_le: Option<DateTime<Utc>>,
65 +```
66 +
67 +- [ ] **Step 2 :** Helper `fn toucher_horodatage(m: &mut Option<DateTime<Utc>>)` ou poser `Some(Utc::now())` à chaque `ecrire_*` create/update (et avant delete pour tombstone timestamp = now).
68 +- [ ] **Step 3 :** Migration serde : fichiers JSON existants sans champ → `None` OK (`#[serde(default)]`).
69 +- [ ] **Verify :** `cargo test -p ailiance-brain --lib` (filtres rdv/ressources si présents) vert.
70 +
71 +---
72 +
73 +## Task S2 — Tombstones + helpers list-since
74 +
75 +**Files :**
76 +- Modify: `src/services/sync.rs` (`TypeTombstone`, `CompteursSync`, `PullSync`, `status`, `pull`)
77 +- Modify: tous les `supprimer_rendezvous` / `supprimer_reservation` / `supprimer_indisponibilite` chemins API+UI critiques → `enregistrer_tombstone` **avant** hard delete
78 +
79 +Schéma type JSON inchangé :
80 +
81 +```json
82 +{ "type": "rdv|reservation|indisponibilite", "id": "…", "projet_id": null, "supprime_le": "…" }
83 +```
84 +
85 +Pour résa/indispo, stocker aussi la cible dans un champ optionnel si utile au client :
86 +
87 +```json
88 +{ "type": "reservation", "id": "…", "cible_type": "salle", "cible_id": "abc", "supprime_le": "…" }
89 +```
90 +
91 +(`cible_type`/`cible_id` : `#[serde(default)]` pour ne pas casser tombstones v1.)
92 +
93 +- [ ] Étendre `Tombstone` + `TypeTombstone`.
94 +- [ ] Brancher deletes RDV/résa/indispo (au minimum chemins qui seront exposés API ; idéalement UI aussi).
95 +- [ ] **Verify :** test unitaire `tombstones_depuis` filtre les nouveaux types.
96 +
97 +---
98 +
99 +## Task S3 — API CRUD `/api/rdv`
100 +
101 +**Files :**
102 +- Modify: `src/handlers/api.rs`
103 +- Test: `tests/api_agenda_http.rs` (créer)
104 +
105 +Contrat :
106 +
107 +```http
108 +GET /api/rdv → [RendezVous] # option: ?mine=1 défaut pour mobile = filtre utilisateur==auteur clé
109 +POST /api/rdv → 201 RendezVous
110 +GET /api/rdv/:id
111 +PUT /api/rdv/:id
112 +DELETE /api/rdv/:id → 204 + tombstone
113 +```
114 +
115 +Body create/update (snake_case) :
116 +
117 +```json
118 +{
119 + "titre": "…",
120 + "description": "",
121 + "lieu": "",
122 + "debut": "ISO-8601",
123 + "fin": "ISO-8601",
124 + "contact_ids": [],
125 + "projet_id": null
126 +}
127 +```
128 +
129 +Règles :
130 +- `utilisateur` / `cree_par` = user Bearer (ignorer spoof).
131 +- `peut_modifier` pour PUT/DELETE.
132 +- Valider via `RendezVous::valider`.
133 +- Poser `mis_a_jour_le`.
134 +
135 +- [ ] **RED :** test HTTP create → get → auteur = user clé ; mauvaise clé 401 ; autre user ne peut pas DELETE.
136 +- [ ] **GREEN :** handlers + routes dans le routeur Bearer (pas `/auth/cle`).
137 +- [ ] OpenAPI : documenter les 5 routes.
138 +- [ ] **Verify :** `cargo test -p ailiance-brain --test api_agenda_http -- --nocapture`
139 +
140 +---
141 +
142 +## Task S4 — API catalogue ressources + CRUD réservations + GET indispos
143 +
144 +**Files :** `api.rs`, éventuellement petit module `handlers/api_ressources.rs` si `api.rs` trop gros
145 +**Test :** `api_agenda_http.rs`
146 +
147 +### Catalogue
148 +
149 +```http
150 +GET /api/ressources
151 +```
152 +
153 +Réponse :
154 +
155 +```json
156 +{
157 + "salles": [ { "id", "nom", "actif", … } ],
158 + "materiels": [ … ],
159 + "vehicules": [ … ]
160 +}
161 +```
162 +
163 +Uniquement ressources `actif == true` pour le mobile (ou toutes + flag `actif` — préférer **toutes** avec `actif` pour que Paramètres puisse griser).
164 +
165 +### Réservations
166 +
167 +```http
168 +GET /api/ressources/:genre/:id/reservations
169 +POST /api/ressources/:genre/:id/reservations
170 +PUT /api/ressources/:genre/:id/reservations/:rid
171 +DELETE /api/ressources/:genre/:id/reservations/:rid
172 +```
173 +
174 +`genre` ∈ `salle|materiel|vehicule` (pas `utilisateur` en v2 mobile).
175 +
176 +Body create :
177 +
178 +```json
179 +{
180 + "debut": "…",
181 + "fin": "…",
182 + "motif": "…",
183 + "projet_id": null,
184 + "rdv_id": null
185 +}
186 +```
187 +
188 +- `reserve_par` = Bearer user ; `statut` = **Active** forcé.
189 +- Appeler `ressources::creer_reservation` / `modifier_reservation`.
190 +- Sur `ErreurReservation::Conflit` → **HTTP 409** + JSON `{ "message": "…", "reclamable": bool }` (**ne pas** créer EnAttente depuis l’API mobile).
191 +
192 +### Indispos (lecture)
193 +
194 +```http
195 +GET /api/ressources/:genre/:id/indisponibilites
196 +```
197 +
198 +- [ ] Tests : create résa OK ; second chevauchant → 409 ; delete → 204.
199 +- [ ] OpenAPI.
200 +- [ ] **Verify :** mêmes tests `api_agenda_http`.
201 +
202 +---
203 +
204 +## Task S5 — Étendre `/api/sync/status` et `/api/sync/pull`
205 +
206 +**Files :** `services/sync.rs`, `handlers/api.rs`
207 +
208 +Query additionnelle (optionnelle) :
209 +
210 +```http
211 +GET /api/sync/pull?since=…&ressources=salle:abc,vehicule:xyz
212 +GET /api/sync/status?since=…&ressources=…
213 +```
214 +
215 +Parser `ressources` → `Vec<CibleRessource>` ; vide = **ne pas** renvoyer résas/indispos (client n’a lié aucun calendrier ressource) ; RDV toujours filtrés `utilisateur == Bearer`.
216 +
217 +Étendre `CompteursSync` / JSON `changes` :
218 +
219 +```json
220 +"changes": {
221 + "contacts": 0, "entreprises": 0, "projets": 0, "taches": 0, "interactions": 0,
222 + "rdv": 1, "reservations": 2, "indisponibilites": 1, "tombstones": 0
223 +}
224 +```
225 +
226 +`PullSync` :
227 +
228 +```json
229 +{
230 + "server_time": "…",
231 + "contacts": [], "entreprises": [], "projets": [], "taches": [], "interactions": [],
232 + "rdv": [ /* RendezVous */ ],
233 + "reservations": [ /* Reservation */ ],
234 + "indisponibilites": [ /* Indisponibilite */ ],
235 + "tombstones": [],
236 + "workflows": []
237 +}
238 +```
239 +
240 +Filtre `since` : `mis_a_jour_le.unwrap_or(cree_le) > since` (RDV/résa/indispo).
241 +
242 +Compat clients v1 : champs JSON **nouveaux avec défaut** `[]` / `0` — anciens parseurs Kotlin devront être mis à jour (Task M*). Serde côté serveur : toujours émettre les clés.
243 +
244 +- [ ] Tests : create RDV → status.rdv >= 1 pour owner ; autre user → 0 ; résa sur salle filtrée par query `ressources`.
245 +- [ ] **Verify :** `cargo test -p ailiance-brain --test api_agenda_http --test api_sync_http`
246 +
247 +---
248 +
249 +## Task S6 — Doc admin serveur
250 +
251 +**Files :** `Projectiaon/docs/guide-administrateur.md`, OpenAPI déjà S3–S5
252 +
253 +- [ ] Documenter `/api/rdv`, `/api/ressources`, sync `rdv`/`reservations`/`indisponibilites`, query `ressources=`, code 409.
254 +- [ ] Lien vers la spec Card2vcf v2.
255 +- [ ] **Verify :** relecture manuelle ; `cargo test --all-targets` vert sur Projectiaon.
256 +
257 +---
258 +
259 +# Partie B — Mobile
260 +
261 +## Task M1 — Permissions + `CalendarBindingsStore`
262 +
263 +**Files :**
264 +- Modify: `android/app/src/main/AndroidManifest.xml`
265 +- Create: `…/sync/CalendarBindingsStore.kt`
266 +- Test: `…/sync/CalendarBindingsStoreTest.kt`
267 +
268 +```kotlin
269 +@Serializable
270 +data class CalendarBinding(
271 + val kind: String, // "rdv" | "salle" | "materiel" | "vehicule"
272 + val serverResourceId: String? = null,
273 + val displayName: String,
274 + val androidCalendarId: Long? = null,
275 +)
276 +```
277 +
278 +- [ ] Permissions `READ_CALENDAR` / `WRITE_CALENDAR`.
279 +- [ ] Store JSON dans prefs (EncryptedSharedPreferences ou prefs dédiées sync — réutiliser pattern credentials).
280 +- [ ] `list()`, `upsert(binding)`, `remove(kind, serverResourceId?)`.
281 +- [ ] **Verify :** unit test round-trip.
282 +
283 +---
284 +
285 +## Task M2 — `CalendarBridge` (TDD sur mapping pur + smoke)
286 +
287 +**Files :**
288 +- Create: `…/sync/CalendarBridge.kt`
289 +- Create: `…/sync/CalendarEventSnapshot.kt` (data class titre, debutMs, finMs, eventId, serverIdExtra?)
290 +- Test: mapping helpers sans ContentResolver si possible ; Robolectric limité pour insert calendar
291 +
292 +API :
293 +
294 +```kotlin
295 +class CalendarBridge(private val resolver: ContentResolver) {
296 + fun ensureLocalCalendar(displayName: String): Long
297 + fun listEvents(calendarId: Long): List<CalendarEventSnapshot>
298 + fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long
299 + fun deleteEvent(eventId: Long)
300 +}
301 +```
302 +
303 +- Calendriers **locaux** (`ACCOUNT_TYPE` local / `Calendars.ACCOUNT_NAME = "card2vcf"`) pour hors-GMS.
304 +- Stocker `serverId` dans `Events.DESCRIPTION` préfixe stable `card2vcf:serverId=…\n` **ou** extended properties si fiable ; documenter le choix dans le code.
305 +- Titre indispo : préfixe `[Indispo] `.
306 +
307 +- [ ] **Verify :** tests mapping parse/format serverId ; `assembleDebug` OK.
308 +
309 +---
310 +
311 +## Task M3 — Room v3 : RDV / Reservation / Indisponibilite
312 +
313 +**Files :** entities + DAOs ; `CrmDatabase` version **3** + `fallbackToDestructiveMigration` (doc README)
314 +
315 +Champs alignés spec §4.2 (`dirtyLocal`, `conflictPending`, `calendarEventId`, …).
316 +
317 +- [ ] DAOs : upsert, getByServerId, listDirty, deleteByServerId, listByCible.
318 +- [ ] Test smoke Robolectric `AgendaSchemaDaoTest`.
319 +- [ ] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*AgendaSchema*'` + tests data existants.
320 +
321 +---
322 +
323 +## Task M4 — DTOs + `AilianceApi` méthodes agenda
324 +
325 +**Files :** `SyncModels.kt`, `AilianceApi.kt`, `AilianceApiClient.kt`
326 +**Test :** MockWebServer
327 +
328 +- DTOs `RendezVousDto`, `ReservationDto`, `IndisponibiliteDto`, `RessourcesCatalogueDto`.
329 +- Étendre `SyncStatusResponse.changes` + `SyncPullResponse` avec `rdv`, `reservations`, `indisponibilites` (défaut empty).
330 +- `syncStatus(since, ressourcesQuery: String?)`, `syncPull(since, ressourcesQuery: String?)`.
331 +- CRUD rdv + résa ; `GET /api/ressources`.
332 +- Parser 409 → `ApiResult.Err(409, message)`.
333 +
334 +- [ ] **Verify :** tests client verts ; régessions `AilianceApiClientTest`.
335 +
336 +---
337 +
338 +## Task M5 — SyncEngine : RDV LWW + résas + Agenda↔Room
339 +
340 +**Files :** `SyncEngine.kt` (+ collaborator `AgendaSyncCoordinator` si fichier trop gros)
341 +
342 +Étendre `syncNow(bindings: List<CalendarBinding>, bridge: CalendarBridge)` :
343 +
344 +1. `agendaToRoom(bindings)` → dirty → SyncOp `rdv`/`reservation`
345 +2. `pushOps()` — sur résa create/update si `Err.code == 409` : set `conflictPending`, **ne pas** drop SyncOp tout de suite ; retourner `SyncResult(conflicts = […])`
346 +3. `pull` avec query `ressources=` dérivée des bindings
347 +4. apply LWW RDV ; résas Active ; indispos append/replace by serverId
348 +5. `roomToAgenda(bindings)`
349 +6. watermark
350 +
351 +`localAheadCount()` : dirty Room + SyncOp pending rdv/reservation (+ events orphelins).
352 +
353 +- [ ] Tests Fake API + in-memory Room : LWW RDV ; 409 set conflictPending ; cancel path helper `abandonLocalReservation(localId)`.
354 +- [ ] **Verify :** `SyncEngineTest` + nouveaux tests.
355 +
356 +---
357 +
358 +## Task M6 — UI Paramètres calendriers
359 +
360 +**Files :** `SettingsScreen.kt`, `SettingsViewModel.kt`, strings FR/EN
361 +
362 +- Demande runtime permissions calendrier.
363 +- Toggle Mes RDV → `ensureLocalCalendar("Card2vcf — Mes RDV")` + binding.
364 +- Fetch catalogue ressources si credentials → toggles par ressource active.
365 +- Confirmation avant remove + delete calendar optionnel.
366 +
367 +- [ ] **Verify :** `assembleDebug` ; test ViewModel permissions/bindings mock si possible.
368 +
369 +---
370 +
371 +## Task M7 — Bandeaux + dialog conflit
372 +
373 +**Files :** `SyncBanner.kt`, `SyncChromeViewModel.kt`, strings
374 +
375 +- Bandeau A : changements serveur (`pendingRemoteChanges`) — existant.
376 +- Bandeau B : `localCalendarAhead > 0` → « Calendrier local en avance sur le serveur ».
377 +- Après `syncNow`, si `conflicts` non vide → dialog « Annuler ma réservation ? » (une par une ou première).
378 + - Oui → `abandonLocalReservation` + delete event Agenda.
379 + - Non → dismiss ; SyncOp peut rester / flag `conflictPending` (manager serveur).
380 +
381 +- [ ] **Verify :** test ViewModel conflit ; compile.
382 +
383 +---
384 +
385 +## Task M8 — Doc README Card2vcf
386 +
387 +**Files :** `EBII_mobileVCF/README.md`
388 +
389 +- Section Agenda / calendriers : permissions, Paramètres, pas d’écran agenda, conflit 409, hors-GMS calendriers locaux.
390 +- Lien spec + ce plan.
391 +- Nuancer destructive migration Room v3.
392 +
393 +- [ ] **Verify :** relecture ; `./gradlew :app:testDebugUnitTest assembleDebug`.
394 +
395 +---
396 +
397 +## Ordre / commits suggérés (si demandé)
398 +
399 +1. `feat(api): mis_a_jour_le rdv reservation indispo`
400 +2. `feat(sync): tombstones agenda types`
401 +3. `feat(api): CRUD /api/rdv`
402 +4. `feat(api): ressources catalog + reservations 409`
403 +5. `feat(api): sync pull rdv reservations indispos`
404 +6. `docs(admin): agenda mobile API`
405 +7. `feat(android): calendar bindings + permissions`
406 +8. `feat(android): CalendarBridge local calendars`
407 +9. `feat(android): Room v3 agenda entities`
408 +10. `feat(android): API DTOs agenda`
409 +11. `feat(android): SyncEngine agenda bridge`
410 +12. `feat(ui): settings calendars + conflict dialog`
411 +13. `docs: README agenda sync`
412 +
413 +## Critère de done v2
414 +
415 +- Curl Bearer : CRUD RDV + résa ; 409 chevauchement ; sync pull filtre `ressources=` + mes RDV.
416 +- App : lier Mes RDV + une salle → events Agenda ↔ serveur après Sync.
417 +- Conflit : deux events visibles + dialog annulation.
418 +- Bandeau local en avance.
419 +- `cargo test --all-targets` (Projectiaon) et `./gradlew :app:testDebugUnitTest assembleDebug` verts.
420 +- Pas d’écran agenda dans la nav.
421 +
422 +## Coverage spec (self-check)
423 +
424 +| Spec § | Tasks |
425 +|---|---|
426 +| RDV LWW + mes RDV | S1, S3, S5, M3–M5 |
427 +| Multi-calendriers Paramètres | M1, M6 |
428 +| Event ressource → résa Active | S4, M5 |
429 +| Indispos lecture | S4, S5, M3, M5 |
430 +| 409 → refresh + annuler | S4, M5, M7 |
431 +| Bandeau local en avance | M5, M7 |
432 +| Pas UI agenda / pas demandes mobile | M6–M8 (hors scope respecté) |
433 +| Doc | S6, M8 |
A docs/plans/sync_card2vcf_projectiaon_v1.md
+342 -0
@@ -0,0 +1,342 @@
1 +# Sync Card2vcf ↔ Projectiaon — Plan client v1
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD (RED → GREEN). **Ne pas committer** sauf demande explicite de l’utilisateur.
4 +
5 +**Goal :** Card2vcf synchronise offline bidirectionnellement CRM + projets/tâches kanban avec Projectiaon (ailiance-brain), via clés API par utilisateur et sync incrémental.
6 +
7 +**Architecture :** Room local + file `SyncOp` ; `SyncEngine` pousse les ops puis tire `/api/sync/pull` ; LWW sur horodatages pour contacts/entreprises/projets/tâches ; append-only pour interactions (CR). Auth : login `POST /api/auth/cle` → déchiffrement local Argon2id + XChaCha20-Poly1305 → Bearer en stockage chiffré.
8 +
9 +**Tech Stack :** Kotlin, Jetpack Compose, Room 2.6, OkHttp + kotlinx.serialization, EncryptedSharedPreferences, Argon2 (params crate Rust `argon2` 0.5) + XChaCha20-Poly1305, Coroutines. Design : `design.md` (N&B éditorial, coins 0).
10 +
11 +**Prérequis serveur (FAIT) :** plan [`Projectiaon/docs/plans/sync_api_mobile_v1.md`](../../../Projectiaon/docs/plans/sync_api_mobile_v1.md) — code présent : `AILIANCE_API_CLES`, `POST /api/auth/cle`, tombstones, `GET /api/sync/status|pull`, `assigne_a` / `mis_a_jour_le`.
12 +
13 +**Hors scope v1 :** RDV, ressources/réservations, Agenda Android, médias serveur, multi-assignés.
14 +
15 +---
16 +
17 +## Décisions produit (validées)
18 +
19 +| Sujet | Décision |
20 +|---|---|
21 +| Rôle mobile | CRM terrain offline = mêmes infos serveur, partage équipe |
22 +| Sync | Incrémental + file d’ops (pas full overwrite seul) |
23 +| Auth | Clés par user ; mdp jamais stocké ; retape pour déchiffrer la clé une fois |
24 +| Conflits | LWW horodatage ; CR append-only ; move kanban = LWW `(colonne_id, ordre)` |
25 +| Déclenchement | Manuel + bandeau status à l’ouverture (pas de sync auto silencieuse) |
26 +| Kanban | v1 (API taches déjà là) |
27 +| Filtre tâches | Défaut « Mes tâches » (`assigne_a == user`) \| « Toutes » |
28 +
29 +---
30 +
31 +## Contrats serveur à respecter
32 +
33 +### Auth clé
34 +
35 +```http
36 +POST /api/auth/cle
37 +Content-Type: application/json
38 +
39 +{"nom":"alice","mot_de_passe":"…"}
40 +```
41 +
42 +```json
43 +{
44 + "nom": "alice",
45 + "cle_chiffree": "<base64>",
46 + "sel": "<base64>",
47 + "kdf": "argon2id",
48 + "cipher": "xchacha20poly1305"
49 +}
50 +```
51 +
52 +Blob `cle_chiffree` (binaire) : `[0..24) nonce XChaCha20` + ciphertext (clé API UTF-8 + tag Poly1305).
53 +Sel : 16 octets.
54 +KDF : Argon2id **defaults crate `argon2` 0.5.3** — `m=19456` (KiB), `t=2`, `p=1`, output 32 octets.
55 +401 credentials ; 404 user sans clé API.
56 +
57 +### Sync
58 +
59 +```http
60 +GET /api/sync/status?since=1970-01-01T00:00:00Z
61 +Authorization: Bearer <cle>
62 +```
63 +
64 +```json
65 +{
66 + "server_time": "…",
67 + "changes": {
68 + "contacts": 2, "entreprises": 0, "projets": 1,
69 + "taches": 3, "interactions": 1, "tombstones": 1
70 + },
71 + "total": 8
72 +}
73 +```
74 +
75 +```http
76 +GET /api/sync/pull?since=…
77 +```
78 +
79 +```json
80 +{
81 + "server_time": "…",
82 + "contacts": [], "entreprises": [], "projets": [],
83 + "taches": [{ "projet_id": "…", /* flatten Tache */ }],
84 + "interactions": [], "tombstones": [], "workflows": []
85 +}
86 +```
87 +
88 +Tombstone : `{ "type": "contact|entreprise|projet|tache|interaction", "id", "projet_id?", "supprime_le" }`.
89 +Projets pull : `taches[]` vidé (tâches dans `taches[]` top-level). ACL : si `membres` non vide, user Bearer doit ∈ membres.
90 +
91 +### CRUD utiles (Bearer)
92 +
93 +| Action | Méthode |
94 +|---|---|
95 +| Contact | `POST/PUT/DELETE /api/contacts[/:id]` |
96 +| Entreprise | `POST/PUT/DELETE /api/entreprises[/:id]` |
97 +| Projet | `POST/PUT/DELETE /api/projets[/:id]` |
98 +| Tâche | `POST/PUT/DELETE /api/projets/:id/taches[/:tid]` |
99 +| Déplacer | `POST /api/projets/:id/taches/:tid/deplacer` body `{colonne_id, ordre?}` |
100 +| CR | `POST /api/contacts/:id/interactions` |
101 +
102 +Champs clés tâche : `titre`, `description`, `colonne_id`, `ordre`, `assigne_a`, `mis_a_jour_le`.
103 +
104 +---
105 +
106 +## Fichiers (carte)
107 +
108 +| Fichier | Rôle |
109 +|---|---|
110 +| `android/app/src/main/AndroidManifest.xml` | `INTERNET` |
111 +| `android/app/build.gradle.kts` | OkHttp, serialization, security-crypto, argon2, libsodium/BC |
112 +| `…/sync/ApiCleCrypto.kt` | Déchiffrement clé API (miroir Rust) |
113 +| `…/sync/SyncCredentialsStore.kt` | URL + Bearer + nom user (EncryptedSharedPreferences) |
114 +| `…/sync/AilianceApiClient.kt` | HTTP auth/status/pull/CRUD |
115 +| `…/sync/SyncModels.kt` | DTO JSON (pull, tombstone, tache…) |
116 +| `…/sync/SyncOpEntity.kt` + Dao | File d’ops offline |
117 +| `…/sync/SyncEngine.kt` | push → pull → LWW / append / watermark |
118 +| `…/sync/LwwMerger.kt` | Règles fusion horodatage |
119 +| `…/data/*Entity.kt` | `serverId`, CRM fields, Projet/Tache/Interaction/Workflow |
120 +| `…/data/CrmDatabase.kt` | version ↑, entities, `fallbackToDestructiveMigration` v1 OK |
121 +| `…/ui/settings/SettingsScreen.kt` | URL + login clé |
122 +| `…/ui/projets/*` | Liste, fiche, CR |
123 +| `…/ui/kanban/*` | Board + filtre Mes/Toutes |
124 +| `…/ui/nav/Card2vcfNavHost.kt` | Routes + bottom nav Carnet \| Projets \| … |
125 +| `…/ui/sync/SyncBanner.kt` | Bandeau « N changements » |
126 +| `README.md` | Sync optionnelle documentée |
127 +| Tests unitaires sous `android/app/src/test/…` | Crypto, LWW, SyncEngine, filtre assigné |
128 +
129 +---
130 +
131 +## Task 0 — Prérequis serveur (skip si déjà vert)
132 +
133 +**Verify :**
134 +
135 +```bash
136 +cd /home/monsieurb/Documents/devel/Projectiaon
137 +cargo test -p ailiance-brain --test api_sync_http -- --nocapture
138 +```
139 +
140 +- [x] Multi-clés + `/api/auth/cle` + sync status/pull (code Projectiaon présent).
141 +Si rouge : corriger le serveur **avant** le mobile.
142 +
143 +---
144 +
145 +## Task 1 — Dépendances + `INTERNET` + store credentials
146 +
147 +**Files :**
148 +- Modify: `android/app/src/main/AndroidManifest.xml`
149 +- Modify: `android/app/build.gradle.kts`
150 +- Create: `…/sync/SyncCredentialsStore.kt`
151 +- Test: `…/test/…/sync/SyncCredentialsStoreTest.kt` (Robolectric)
152 +
153 +- [x] **Step 1 :** Ajouter permission `INTERNET` (et `usesCleartextTraffic` **non** — HTTPS only ; debug exception optionnelle documentée).
154 +- [x] **Step 2 :** Dépendances : `okhttp`, `kotlinx-serialization-json`, `androidx.security:security-crypto`, lib Argon2 JVM compatible params, lib XChaCha20-Poly1305 (ex. `com.github.ionspin.kotlin:multiplatform-crypto-libsodium` ou BouncyCastle + wrapper — choisir une stack qui tourne en unit test JVM).
155 +- [x] **Step 3 :** `SyncCredentialsStore` : `baseUrl`, `apiKey`, `userName` ; clear ; jamais le mot de passe.
156 +- [x] **Step 4 :** Test Robolectric round-trip store.
157 +- [x] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*SyncCredentialsStore*'`
158 +
159 +---
160 +
161 +## Task 2 — Crypto `ApiCleCrypto` (TDD)
162 +
163 +**Files :**
164 +- Create: `…/sync/ApiCleCrypto.kt`
165 +- Test: `…/test/…/sync/ApiCleCryptoTest.kt`
166 +
167 +- [x] **Step 1 (RED) :** Test vector : sel 16 bytes, mdp connu, blob produit par un fixture base64 **généré côté Rust** (`chiffrer_cle_api`) — coller sel+blob+mdp+clair attendu dans le test.
168 +- [x] **Step 2 (GREEN) :** Implémenter `decryptApiKey(password, saltB64, cipherB64)` avec m=19456, t=2, p=1, Argon2id, XChaCha20-Poly1305, nonce 24 prefix.
169 +- [x] **Step 3 :** Test mauvais mdp → erreur ; blob trop court → erreur.
170 +- [x] **Verify :** unit tests verts. **Ne jamais logger** la clé claire.
171 +
172 +**Aide génération fixture (une fois) :**
173 +
174 +```bash
175 +cd Projectiaon && cargo test -p ailiance-brain --lib api_cle_crypto -- --nocapture
176 +# ou petit bin/println temporaire — retirer ensuite
177 +```
178 +
179 +---
180 +
181 +## Task 3 — Client HTTP + DTOs sync
182 +
183 +**Files :**
184 +- Create: `…/sync/SyncModels.kt`, `…/sync/AilianceApiClient.kt`
185 +- Test: `…/test/…/sync/AilianceApiClientTest.kt` (MockWebServer)
186 +
187 +- [x] `authCle(nom, mdp)` → parse réponse.
188 +- [x] `syncStatus(since)`, `syncPull(since)` Bearer.
189 +- [x] Stubs CRUD contacts / taches create|update|delete|move / interactions create (signatures utilisées par SyncEngine).
190 +- [x] 401/404 mappés en sealed class `ApiResult`.
191 +- [x] **Verify :** MockWebServer tests verts.
192 +
193 +---
194 +
195 +## Task 4 — Schéma Room sync
196 +
197 +**Files :** modifier entities + `CrmDatabase` version `2` ; DAOs ; `Converters` si besoin.
198 +
199 +Extensions contact :
200 +- `serverId: String?`
201 +- `statut`, `etape`, `tags: List<String>`, `entrepriseServerId: String?`
202 +- garder `updatedAt` local ; mapper `mis_a_jour_le` ISO ↔ epoch ms
203 +
204 +Nouvelles tables :
205 +- `entreprises` (`localId`, `serverId`, champs miroir, `updatedAt`)
206 +- `projets` (+ `membresJson`, `workflowServerId`, `updatedAt`)
207 +- `taches` (`projetLocalId` / `projetServerId`, `serverId`, `colonneId`, `ordre`, `assigneA`, `updatedAt`, …)
208 +- `interactions` (`contactServerId`, `serverId`, `type`, `sujet`, `description`, `createdAt` — append-only)
209 +- `workflows` + `workflow_colonnes` (ou JSON colonnes)
210 +- `sync_ops` : `id`, `entityType`, `op` (`create|update|delete|move`), `payloadJson`, `localId?`, `serverId?`, `createdAt`, `attempts`
211 +- prefs watermark : table `sync_meta(key,value)` clé `watermark` = ISO last `server_time`
212 +
213 +- [x] Migration : `fallbackToDestructiveMigration()` acceptable v1 (doc README : reset local à l’upgrade schema).
214 +- [x] Tests DAO smoke (insert/query) Robolectric comme `CrmContactDaoTest`.
215 +- [x] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*Dao*'`
216 +
217 +---
218 +
219 +## Task 5 — `LwwMerger` + règles (TDD)
220 +
221 +**Files :** `…/sync/LwwMerger.kt` + tests
222 +
223 +- [x] Contact/Entreprise/Projet/Tache : si `remote.updatedAt > local.updatedAt` → remote gagne ; égalité → remote gagne (déterministe).
224 +- [x] Interaction : jamais écraser ; insert si `serverId` inconnu.
225 +- [x] Tombstone : supprimer local par `serverId` (+ cascade tâches si projet).
226 +- [x] Move : LWW sur `(colonneId, ordre, updatedAt)`.
227 +- [x] **Verify :** tests pure Kotlin verts.
228 +
229 +---
230 +
231 +## Task 6 — `SyncEngine`
232 +
233 +**Files :** `…/sync/SyncEngine.kt` + tests avec fake API + Room in-memory
234 +
235 +Séquence bouton Sync :
236 +1. Push chaque `SyncOp` (ordre FIFO) via CRUD ; sur succès retirer op + mapper `serverId` si create.
237 +2. `pull(since=watermark)` ; appliquer LWW / append / tombstones ; stocker workflows.
238 +3. Watermark = `server_time` du pull.
239 +
240 +À l’ouverture (léger) :
241 +- Si credentials : `status(since)` → exposer `pendingRemoteChanges = total` (UI bandeau). **Ne pas** pull auto.
242 +
243 +- [x] Tests : 2 contacts LWW ; 2 CR append ; move conflit LWW ; tombstone delete.
244 +- [x] **Verify :** unit tests SyncEngine.
245 +
246 +---
247 +
248 +## Task 7 — UI Paramètres + auth clé
249 +
250 +**Files :** `SettingsScreen`, ViewModel, routes nav, strings FR/EN
251 +
252 +- [x] Champs : URL base HTTPS, nom, mot de passe (temporaire).
253 +- [x] Bouton « Obtenir la clé » → `authCle` → dialog retape mdp → `ApiCleCrypto.decrypt` → store.
254 +- [x] Afficher user connecté + « Déconnecter » (clear store).
255 +- [x] Style `design.md` : coins 0, ink/canvas, Manrope/Lora/Playfair existants.
256 +- [x] Entrée depuis overflow / engrenage carnet.
257 +- [x] **Verify :** compile + smoke manuel ou UI test minimal si déjà du setup.
258 +
259 +---
260 +
261 +## Task 8 — Bandeau sync + câblage ouverture
262 +
263 +**Files :** `SyncBanner.kt`, `CarnetScreen` / scaffold app, `MainActivity`/`Card2vcfApp`
264 +
265 +- [x] Si `pendingRemoteChanges > 0` : bandeau hairline « N changement(s) sur le serveur » + bouton Sync.
266 +- [x] Bouton Sync toujours accessible (Paramètres ou bandeau) même si total=0 (push local).
267 +- [x] Pendant sync : état loading ; erreur réseau affichée (ink, pas snackbar coloré flashy).
268 +- [x] **Verify :** compile ; test ViewModel status mock.
269 +
270 +---
271 +
272 +## Task 9 — Onglet Projets + CR
273 +
274 +**Files :** nav bottom `Carnet | Projets` ; `ProjetsListScreen`, `ProjetDetailScreen` ; création CR = `Interaction` type note liée contact
275 +
276 +- [x] Liste projets Room (après sync).
277 +- [x] Fiche : nom, description, membres, contacts liés (ids serveur résolus localement si possible).
278 +- [x] Section CR : liste interactions du contact sélectionné / du projet (v1 : CR sur contact, append SyncOp create).
279 +- [x] Mutations locales → enqueue SyncOp + update Room optimiste.
280 +- [x] **Verify :** unit tests repository ; compile UI.
281 +
282 +---
283 +
284 +## Task 10 — Kanban + `assigne_a` + filtre
285 +
286 +**Files :** `KanbanBoard.kt`, filter Mes/Toutes, CRUD tâche, swipe/déplacer
287 +
288 +- [x] Colonnes = workflow local du `workflowServerId` du projet.
289 +- [x] Cartes : titre, pastille assigné, ordre.
290 +- [x] Créer / éditer / supprimer / déplacer → Room + SyncOp (`move` avec colonne+ordre).
291 +- [x] Sélecteur `assigne_a` : membres projet (+ soi = `userName` du store).
292 +- [x] Filtre défaut **Mes tâches** ; toggle **Toutes**. Non assigné = visible seulement dans Toutes.
293 +- [x] **Verify :** tests filtre + merge move ; compile.
294 +
295 +---
296 +
297 +## Task 11 — Mapping IDs + push contacts carnet existant
298 +
299 +**Files :** adapters repository carnet ↔ server contact
300 +
301 +- [x] À la création locale d’un contact après login : enqueue create avec payload API.
302 +- [x] Pull contact → upsert par `serverId` ; si match email/tél fort optionnel hors v1 (LWW sur serverId suffit).
303 +- [x] Édition / suppression locale → update/delete SyncOp.
304 +- [x] **Verify :** tests mapping ; pas de régression `ContactRepositoryTest`.
305 +
306 +---
307 +
308 +## Task 12 — Doc README
309 +
310 +**Files :** `README.md`
311 +
312 +- [x] Section Sync (optionnelle) : prérequis serveur, flux clé, hors-ligne, pas GMS.
313 +- [x] Nuancer « aucune permission Internet » → Internet **uniquement** pour sync (OCR reste offline).
314 +- [x] Lien vers ce plan + plan serveur Projectiaon.
315 +
316 +---
317 +
318 +## Ordre d’exécution / commits suggérés (si demandé)
319 +
320 +1. `feat(sync): credentials store + INTERNET + deps`
321 +2. `feat(sync): ApiCleCrypto argon2id xchacha`
322 +3. `feat(sync): AilianceApiClient + DTOs`
323 +4. `feat(sync): Room schema v2 entities`
324 +5. `feat(sync): LwwMerger + SyncEngine`
325 +6. `feat(ui): settings auth key`
326 +7. `feat(ui): sync banner + projets + CR`
327 +8. `feat(ui): kanban assigne_a filter`
328 +9. `docs: README sync optionnelle`
329 +
330 +## Critère de done client v1
331 +
332 +- Login Paramètres → clé déchiffrée stockée ; Bearer sur status/pull
333 +- Sync manuelle : push ops + pull delta ; watermark avancé
334 +- 2 devices / 2 users : LWW contact ; 2 CR présents ; move tâche LWW ; filtre Mes tâches
335 +- Bandeau à l’ouverture si `total > 0` sans pull auto
336 +- `./gradlew :app:testDebugUnitTest` vert ; `assembleDebug` OK
337 +- Pas de clé/mdp en clair dans logs
338 +
339 +## v2 (plan séparé)
340 +
341 +Spec design : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md`](../superpowers/specs/2026-07-22-sync-agenda-rdv-ressources-v2-design.md)
342 +(RDV + calendriers ressources Agenda Android, pas d’écran agenda in-app.)
A docs/plans/sync_cartes_visite_v1.md
+51 -0
@@ -0,0 +1,51 @@
1 +# Sync cartes de visite (images) — v1
2 +
3 +**Date :** 2026-07-23
4 +**Dépôts :** Projectiaon (serveur) + EBII_mobileVCF / Card2vcf (client)
5 +**Statut :** implémenté
6 +
7 +## Objectif
8 +
9 +À la sync, pousser/tirer non seulement le **contact texte**, mais aussi l’**image de carte de visite** (et la photo de profil) déjà gérées côté Projectiaon via `Contact.carte_visite` / `Contact.photo`.
10 +
11 +## Contexte
12 +
13 +La sync CRM v1 excluait explicitement les « médias serveur ». L’upload image n’existait qu’en session web (`POST /contacts/:id/carte`), inutilisable par Card2vcf (Bearer).
14 +
15 +## API serveur (Bearer)
16 +
17 +| Méthode | Route | Rôle |
18 +|---|---|---|
19 +| `POST` | `/api/contacts/:id/carte` | multipart → `carte_visite` |
20 +| `GET` | `/api/contacts/:id/carte` | sert le fichier |
21 +| `POST` | `/api/contacts/:id/photo` | multipart → `photo` |
22 +| `GET` | `/api/contacts/:id/photo` | sert la photo |
23 +
24 +- Auth : même garde Bearer que le reste de `/api`
25 +- Validation : png/jpg/jpeg/webp/gif, max 5 Mo (`medias::enregistrer_upload`)
26 +- POST : `200` + JSON `Contact` (avec `mis_a_jour_le` mis à jour)
27 +- Routes web `/contacts/:id/carte|photo` inchangées (session)
28 +
29 +## Client Card2vcf
30 +
31 +1. **Push** : après `create`/`update` contact JSON réussi, upload `card.jpg` / `profile.jpg` si présents.
32 +2. **Retry** : échec upload → SyncOp `contact_media` (ne re-crée pas le contact).
33 +3. **Rotation** : `rotateCardImage` enqueue un update contact pour re-pousser l’image.
34 +4. **Pull** : si DTO a `carte_visite`/`photo` et (fichier local absent **ou** serveur plus récent), `GET` + `ContactImageStore.saveBytes`. Skip si SyncOp contact/média pending.
35 +
36 +## Hors scope
37 +
38 +- Fichier `.vcf` en pièce jointe
39 +- Champ `PHOTO` dans l’export VCF
40 +- Sync des champs locaux non mappés (`website`, `address`, …)
41 +
42 +## Tests
43 +
44 +- Projectiaon : `tests/api_contacts_media_http.rs`
45 +- Card2vcf : `AilianceApiClientTest` (multipart/download), `SyncEngineTest` (push image, retry, pull)
46 +
47 +## Critères de succès
48 +
49 +1. Scan + sync → vignette carte visible dans l’UI web Projectiaon
50 +2. 2ᵉ appareil / réinstall → pull récupère contact + `card.jpg`
51 +3. Routes web inchangées
A icon.png
+0 -0
A ios/.gitignore
+5 -0
@@ -0,0 +1,5 @@
1 +# Generated by XcodeGen — run `xcodegen generate` in ios/
2 +Card2vcf.xcodeproj/
3 +build/
4 +DerivedData/
5 +*.xcuserstate
A ios/Card2vcf/App/Card2vcfApp.swift
+10 -0
@@ -0,0 +1,10 @@
1 +import SwiftUI
2 +
3 +@main
4 +struct Card2vcfApp: App {
5 + var body: some Scene {
6 + WindowGroup {
7 + RootView()
8 + }
9 + }
10 +}
A ios/Card2vcf/CRM/AlphabetIndex.swift
+68 -0
@@ -0,0 +1,68 @@
1 +import Foundation
2 +
3 +enum AlphabetIndex {
4 + /// Kotlin returned a `LinkedHashMap<Char, List<CrmContactEntity>>`;
5 + /// Swift has no ordered dictionary, so sections are an ordered array of pairs.
6 + typealias Section = (letter: Character, contacts: [CrmContactEntity])
7 +
8 + static func sortKey(_ c: CrmContactEntity, sort: ContactSort) -> String {
9 + switch sort {
10 + case .lastName:
11 + return c.lastName ?? c.fullName ?? ""
12 + case .firstName:
13 + return c.firstName ?? c.fullName ?? ""
14 + case .company:
15 + return c.company ?? ""
16 + case .createdAt:
17 + let s = String(c.createdAt)
18 + return s.count >= 20 ? s : String(repeating: "0", count: 20 - s.count) + s
19 + }
20 + }
21 +
22 + static func sectionLetter(_ key: String) -> Character {
23 + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
24 + guard let first = trimmed.first else { return "#" }
25 + let up = String(first).uppercased()
26 + // Compare on the scalar value like Kotlin's `c in 'A'..'Z'` (so 'É' → '#').
27 + guard up.unicodeScalars.count == 1,
28 + let scalar = up.unicodeScalars.first,
29 + (65...90).contains(scalar.value) else { return "#" }
30 + return Character(up)
31 + }
32 +
33 + /// Alphabetical sorts: ordered A–Z then `#`, contacts sorted within each section.
34 + /// `ContactSort.createdAt`: single flat section (chrono desc), no A–Z rail sections.
35 + static func group(contacts: [CrmContactEntity], sort: ContactSort) -> [Section] {
36 + if sort == .createdAt {
37 + // Stable descending sort by createdAt.
38 + let sorted = contacts.enumerated().sorted { l, r in
39 + if l.element.createdAt != r.element.createdAt {
40 + return l.element.createdAt > r.element.createdAt
41 + }
42 + return l.offset < r.offset
43 + }.map { $0.element }
44 + return [(letter: "#", contacts: sorted)]
45 + }
46 + // Stable case-insensitive sort (Kotlin: compareBy(String.CASE_INSENSITIVE_ORDER)).
47 + let sorted = contacts.enumerated().sorted { l, r in
48 + let cmp = sortKey(l.element, sort: sort).caseInsensitiveCompare(sortKey(r.element, sort: sort))
49 + if cmp != .orderedSame { return cmp == .orderedAscending }
50 + return l.offset < r.offset
51 + }.map { $0.element }
52 + var order: [Character] = []
53 + var buckets: [Character: [CrmContactEntity]] = [:]
54 + for c in sorted {
55 + let letter = sectionLetter(sortKey(c, sort: sort))
56 + if buckets[letter] == nil { order.append(letter) }
57 + buckets[letter, default: []].append(c)
58 + }
59 + var out: [Section] = []
60 + for k in order where k != "#" {
61 + out.append((letter: k, contacts: buckets[k] ?? []))
62 + }
63 + if let hash = buckets["#"] {
64 + out.append((letter: "#", contacts: hash))
65 + }
66 + return out
67 + }
68 +}
A ios/Card2vcf/CRM/ContactMerge.swift
+65 -0
@@ -0,0 +1,65 @@
1 +import Foundation
2 +
3 +struct MergeFieldChoices: Equatable {
4 + var fullNameFromId: Int64
5 + var firstNameFromId: Int64
6 + var lastNameFromId: Int64
7 + var companyFromId: Int64
8 + var jobTitleFromId: Int64
9 + var websiteFromId: Int64
10 + var addressFromId: Int64
11 + var notesMode: NotesMode
12 + var cardImageFromId: Int64?
13 + var profileImageFromId: Int64?
14 + var keepId: Int64
15 +}
16 +
17 +enum NotesMode {
18 + case keepPrimary
19 + case concat
20 +}
21 +
22 +enum ContactMerge {
23 + /// Kotlin took a `Map<Long, CrmContactEntity>` (insertion-ordered `LinkedHashMap`);
24 + /// Swift dictionaries are unordered, so the ordered source list is passed instead
25 + /// and the id lookup is built internally. Union order follows the list order.
26 + static func merge(
27 + sources: [CrmContactEntity],
28 + choices: MergeFieldChoices,
29 + now: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
30 + ) -> CrmContactEntity {
31 + let byId = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
32 + func scalar(_ picker: Int64, _ sel: (CrmContactEntity) -> String?) -> String? {
33 + sel(byId[picker]!)
34 + }
35 + let keep = byId[choices.keepId]!
36 + let phones = sources.flatMap { $0.phones }.distinctPreservingOrder()
37 + let emails = sources.flatMap { $0.emails }.distinctPreservingOrder()
38 + let notes: String?
39 + switch choices.notesMode {
40 + case .keepPrimary:
41 + notes = keep.notes
42 + case .concat:
43 + notes = sources
44 + .compactMap { $0.notes?.nilIfBlank }
45 + .distinctPreservingOrder()
46 + .joined(separator: "\n---\n")
47 + .nilIfBlank
48 + }
49 + var merged = keep
50 + merged.fullName = scalar(choices.fullNameFromId) { $0.fullName }
51 + merged.firstName = scalar(choices.firstNameFromId) { $0.firstName }
52 + merged.lastName = scalar(choices.lastNameFromId) { $0.lastName }
53 + merged.company = scalar(choices.companyFromId) { $0.company }
54 + merged.jobTitle = scalar(choices.jobTitleFromId) { $0.jobTitle }
55 + merged.website = scalar(choices.websiteFromId) { $0.website }
56 + merged.address = scalar(choices.addressFromId) { $0.address }
57 + merged.notes = notes
58 + merged.phones = phones
59 + merged.emails = emails
60 + merged.phonesText = phones.joined(separator: " ")
61 + merged.emailsText = emails.joined(separator: " ")
62 + merged.updatedAt = now
63 + return merged
64 + }
65 +}
A ios/Card2vcf/CRM/ContactNormalizer.swift
+14 -0
@@ -0,0 +1,14 @@
1 +import Foundation
2 +
3 +enum ContactNormalizer {
4 + static func email(_ raw: String) -> String {
5 + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
6 + }
7 +
8 + /// Kotlin `filter { it.isDigit() }` — Unicode Nd digits only.
9 + static func phone(_ raw: String) -> String {
10 + String(String.UnicodeScalarView(raw.unicodeScalars.filter {
11 + CharacterSet.decimalDigits.contains($0)
12 + }))
13 + }
14 +}
A ios/Card2vcf/CRM/ContactSort.swift
+8 -0
@@ -0,0 +1,8 @@
1 +import Foundation
2 +
3 +enum ContactSort {
4 + case lastName
5 + case firstName
6 + case company
7 + case createdAt
8 +}
A ios/Card2vcf/CRM/DuplicateDetector.swift
+68 -0
@@ -0,0 +1,68 @@
1 +import Foundation
2 +
3 +enum DuplicateDetector {
4 + private struct IdPair: Hashable {
5 + let lo: Int64
6 + let hi: Int64
7 + }
8 +
9 + static func clusters(
10 + contacts: [CrmContactEntity],
11 + exclusions: [DuplicateExclusionEntity]
12 + ) -> [Int64: Set<Int64>] {
13 + var parent: [Int64: Int64] = [:]
14 + for c in contacts { parent[c.id] = c.id }
15 + func find(_ x: Int64) -> Int64 {
16 + var p = x
17 + while parent[p] != p { p = parent[p]! }
18 + return p
19 + }
20 + func union(_ a: Int64, _ b: Int64) {
21 + let ra = find(a)
22 + let rb = find(b)
23 + if ra != rb { parent[ra] = rb }
24 + }
25 + let excluded = Set(exclusions.map { IdPair(lo: $0.idA, hi: $0.idB) })
26 + func excludedPair(_ a: Int64, _ b: Int64) -> Bool {
27 + excluded.contains(IdPair(lo: min(a, b), hi: max(a, b)))
28 + }
29 + var byEmail: [String: [Int64]] = [:]
30 + var byPhone: [String: [Int64]] = [:]
31 + // Preserve deterministic insertion order of the groups (Kotlin LinkedHashMap-like
32 + // ordering does not affect the resulting clusters, only union order).
33 + var emailKeys: [String] = []
34 + var phoneKeys: [String] = []
35 + for c in contacts {
36 + for e in c.emails.map(ContactNormalizer.email) where !e.isEmpty {
37 + if byEmail[e] == nil { emailKeys.append(e) }
38 + byEmail[e, default: []].append(c.id)
39 + }
40 + for p in c.phones.map(ContactNormalizer.phone) where !p.isEmpty {
41 + if byPhone[p] == nil { phoneKeys.append(p) }
42 + byPhone[p, default: []].append(c.id)
43 + }
44 + }
45 + func linkGroups(_ groups: [[Int64]]) {
46 + for g in groups where g.count > 1 {
47 + for i in 1..<g.count {
48 + if !excludedPair(g[0], g[i]) { union(g[0], g[i]) }
49 + }
50 + }
51 + }
52 + linkGroups(emailKeys.compactMap { byEmail[$0] })
53 + linkGroups(phoneKeys.compactMap { byPhone[$0] })
54 + var rootToMembers: [Int64: Set<Int64>] = [:]
55 + for c in contacts {
56 + rootToMembers[find(c.id), default: []].insert(c.id)
57 + }
58 + var idToCluster: [Int64: Set<Int64>] = [:]
59 + for members in rootToMembers.values {
60 + for id in members { idToCluster[id] = members }
61 + }
62 + return idToCluster
63 + }
64 +
65 + static func duplicateCount(_ contactId: Int64, clusters: [Int64: Set<Int64>]) -> Int {
66 + max((clusters[contactId]?.count ?? 1) - 1, 0)
67 + }
68 +}
A ios/Card2vcf/Contact/ContactCard.swift
+32 -0
@@ -0,0 +1,32 @@
1 +import Foundation
2 +
3 +struct ContactCard: Equatable {
4 + var fullName: String? = nil
5 + var firstName: String? = nil
6 + var lastName: String? = nil
7 + var company: String? = nil
8 + var jobTitle: String? = nil
9 + var phones: [String] = []
10 + var emails: [String] = []
11 + var website: String? = nil
12 + var address: String? = nil
13 + var note: String? = nil
14 +
15 + func displayName() -> String {
16 + if let fullName = fullName?.nilIfBlank { return fullName }
17 + let joined = [firstName, lastName].compactMap { $0 }.joined(separator: " ")
18 + if joined.isNotBlank { return joined }
19 + return company ?? ""
20 + }
21 +
22 + func hasAnyField() -> Bool {
23 + displayName().isNotBlank
24 + || company?.isNotBlank == true
25 + || jobTitle?.isNotBlank == true
26 + || !phones.isEmpty
27 + || !emails.isEmpty
28 + || website?.isNotBlank == true
29 + || address?.isNotBlank == true
30 + || note?.isNotBlank == true
31 + }
32 +}
A ios/Card2vcf/Contact/ContactDraftMerge.swift
+38 -0
@@ -0,0 +1,38 @@
1 +import Foundation
2 +
3 +/// Merges sources, by reliability priority:
4 +/// 1. OCR regexes (phone / email / url) — always win
5 +/// 2. Optional LLM card (`llm`) if present and non-empty — Card2vcf passes `nil`
6 +/// 3. Deterministic heuristics (name / company / job / address)
7 +enum ContactDraftMerge {
8 + static func merge(llm: ContactCard?, ocr: OcrResult) -> ContactCard {
9 + let heuristic = ContactHeuristicParser.parse(ocr)
10 + let base = llm ?? ContactCard()
11 + return ContactCard(
12 + fullName: prefer(base.fullName, heuristic.fullName),
13 + firstName: prefer(base.firstName, heuristic.firstName),
14 + lastName: prefer(base.lastName, heuristic.lastName),
15 + company: prefer(base.company, heuristic.company),
16 + jobTitle: prefer(base.jobTitle, heuristic.jobTitle),
17 + phones: mergeStrings(preferred: ocr.phones, extra: base.phones + heuristic.phones),
18 + emails: mergeStrings(preferred: ocr.emails, extra: base.emails + heuristic.emails),
19 + website: prefer(ocr.urls.first, prefer(base.website, heuristic.website)),
20 + address: prefer(base.address, heuristic.address),
21 + note: prefer(base.note, heuristic.note)
22 + )
23 + }
24 +
25 + private static func prefer(_ primary: String?, _ fallback: String?) -> String? {
26 + primary?.nilIfBlank ?? fallback?.nilIfBlank
27 + }
28 +
29 + private static func mergeStrings(preferred: [String], extra: [String]) -> [String] {
30 + var seen = Set<String>()
31 + var out: [String] = []
32 + for v in preferred + extra {
33 + let n = v.trimmingCharacters(in: .whitespacesAndNewlines)
34 + if !n.isEmpty && seen.insert(n).inserted { out.append(n) }
35 + }
36 + return out
37 + }
38 +}
A ios/Card2vcf/Contact/ContactHeuristicParser.swift
+219 -0
@@ -0,0 +1,219 @@
1 +import Foundation
2 +
3 +/// Deterministic structuring of business-card OCR text.
4 +/// Single structuring source in Card2vcf (no LLM).
5 +enum ContactHeuristicParser {
6 +
7 + private static let jobKeywords = KotlinRegex(
8 + #"(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|"# +
9 + #"manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|"# +
10 + #"associé|associe|fondateur|founder|head of|chef de|avocat|docteur|dr\.?)\b"#
11 + )
12 + private static let addressKeywords = KotlinRegex(
13 + #"(?i)\b(rue|avenue|av\.?|bd\.?|boulevard|all[ée]e|chemin|place|impasse|"# +
14 + #"cedex|cs\b|bp\b|boîte postale|france|siège|siege)\b|\b\d{5}\b"#
15 + )
16 + private static let companyKeywords = KotlinRegex(
17 + #"(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|"# +
18 + #"group|groupe|technologies|tech|consulting)\b"#
19 + )
20 + private static let skipLine = KotlinRegex(
21 + #"(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|"# +
22 + #"siège social|siege social|linkedin|twitter)\b"#
23 + )
24 + private static let emailOrUrlOrPhone = KotlinRegex(
25 + #"@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d"#,
26 + options: [.caseInsensitive]
27 + )
28 + private static let nonNameChars = KotlinRegex(#"[^\p{L}\s\-']"#)
29 + private static let whitespaceRun = KotlinRegex(#"\s+"#)
30 + private static let companySeparators = KotlinRegex(#"[&—–\-_|]+"#)
31 + private static let camelBoundary = KotlinRegex(#"(?<=[a-z])(?=[A-Z])"#)
32 +
33 + static func parse(_ ocr: OcrResult) -> ContactCard {
34 + let lines: [String]
35 + if ocr.lines.isEmpty {
36 + lines = ocr.rawText.kotlinLines()
37 + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
38 + .filter { !$0.isEmpty }
39 + } else {
40 + lines = ocr.lines
41 + }
42 + var residual = lines
43 +
44 + func takeMatching(_ pred: (String) -> Bool) -> String? {
45 + guard let idx = residual.firstIndex(where: pred) else { return nil }
46 + return residual.remove(at: idx)
47 + }
48 + func removeFirst(_ element: String) {
49 + if let idx = residual.firstIndex(of: element) { residual.remove(at: idx) }
50 + }
51 + // Kotlin maxByOrNull: first element with the maximum selector value.
52 + func firstMax(_ candidates: [String], by score: (String) -> Int) -> String? {
53 + var best: String? = nil
54 + var bestScore = Int.min
55 + for c in candidates {
56 + let s = score(c)
57 + if s > bestScore {
58 + bestScore = s
59 + best = c
60 + }
61 + }
62 + return best
63 + }
64 +
65 + let jobTitle = takeMatching {
66 + jobKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0)
67 + }
68 +
69 + let addressCandidates = residual.filter {
70 + addressKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0)
71 + }
72 + let address = firstMax(addressCandidates) { $0.count }
73 + if let address { removeFirst(address) }
74 +
75 + let personLine = firstMax(residual.filter { looksLikePersonName($0) }) { personScore($0) }
76 + if let personLine { removeFirst(personLine) }
77 +
78 + let (firstName, lastName, fullName) = splitPersonName(personLine)
79 +
80 + let companyFromEmail = ocr.emails.first.flatMap { companyFromEmailDomain($0) }
81 + let companyLine = firstMax(residual.filter { looksLikeCompany($0) }) { companyScore($0) }
82 + if let companyLine { removeFirst(companyLine) }
83 + let company = cleanCompany(companyLine) ?? companyFromEmail
84 +
85 + let website = ocr.urls.first
86 + let noteParts = residual.filter { line in
87 + !skipLine.containsMatchIn(line)
88 + && !emailOrUrlOrPhone.containsMatchIn(line)
89 + && line.count > 2
90 + }
91 +
92 + return ContactCard(
93 + fullName: fullName,
94 + firstName: firstName,
95 + lastName: lastName,
96 + company: company,
97 + jobTitle: cleanJob(jobTitle),
98 + phones: ocr.phones,
99 + emails: ocr.emails,
100 + website: website,
101 + address: cleanAddress(address),
102 + note: noteParts.prefix(3).joined(separator: " · ").nilIfBlank
103 + )
104 + }
105 +
106 + static func looksLikePersonName(_ line: String) -> Bool {
107 + let t = nonNameChars.replace(line, with: " ")
108 + .trimmingCharacters(in: .whitespacesAndNewlines)
109 + guard (4...60).contains(t.count) else { return false }
110 + if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false }
111 + if addressKeywords.containsMatchIn(t) { return false }
112 + if emailOrUrlOrPhone.containsMatchIn(line) { return false }
113 + let parts = whitespaceRun.split(t).filter { $0.isNotBlank }
114 + guard (2...4).contains(parts.count) else { return false }
115 + // At least two capitalized / uppercase tokens.
116 + let caps = parts.filter { $0.first?.isUppercase == true }.count
117 + return caps >= 2
118 + }
119 +
120 + static func personScore(_ line: String) -> Int {
121 + let parts = whitespaceRun.split(
122 + nonNameChars.replace(line, with: " ").trimmingCharacters(in: .whitespacesAndNewlines)
123 + )
124 + var s = parts.count * 10
125 + if parts.contains(where: { $0.count >= 2 && $0 == $0.uppercased() && $0.contains(where: { $0.isLetter }) }) {
126 + s += 40
127 + }
128 + if (2...3).contains(parts.count) { s += 15 }
129 + return s
130 + }
131 +
132 + static func splitPersonName(_ line: String?) -> (firstName: String?, lastName: String?, fullName: String?) {
133 + guard let line, line.isNotBlank else { return (nil, nil, nil) }
134 + let cleaned = whitespaceRun.replace(
135 + nonNameChars.replace(line.replacingOccurrences(of: "!", with: "l"), with: " "), // Pau! → Paul (frequent OCR error)
136 + with: " "
137 + ).trimmingCharacters(in: .whitespacesAndNewlines)
138 + let parts = cleaned.components(separatedBy: " ").filter { $0.isNotBlank }
139 + if parts.isEmpty { return (nil, nil, nil) }
140 + let lastCaps = parts.lastIndex {
141 + $0.count >= 2 && $0 == $0.uppercased() && $0.allSatisfy { c in !c.isLetter || c.isUppercase }
142 + }
143 + func titleWord(_ w: String) -> String {
144 + let lower = w.lowercased()
145 + guard let f = lower.first else { return lower }
146 + return String(f).uppercased() + lower.dropFirst()
147 + }
148 + if let lastCaps, lastCaps > 0 {
149 + 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: " ")
152 + return (firstKeep, last, "\(firstKeep) \(last)")
153 + } else if parts.count >= 2 {
154 + let last = titleWord(parts[parts.count - 1])
155 + let first = parts.dropLast().joined(separator: " ")
156 + return (first, last, "\(first) \(last)")
157 + } else {
158 + return (nil, nil, cleaned)
159 + }
160 + }
161 +
162 + private static func looksLikeCompany(_ line: String) -> Bool {
163 + guard (2...50).contains(line.count) else { return false }
164 + if emailOrUrlOrPhone.containsMatchIn(line) { return false }
165 + if jobKeywords.containsMatchIn(line) { return false }
166 + if addressKeywords.containsMatchIn(line) { return false }
167 + if looksLikePersonName(line) { return false }
168 + return companyKeywords.containsMatchIn(line)
169 + || line == line.uppercased()
170 + || whitespaceRun.split(line).count <= 3
171 + }
172 +
173 + private static func companyScore(_ line: String) -> Int {
174 + var s = 0
175 + if companyKeywords.containsMatchIn(line) { s += 50 }
176 + if line == line.uppercased() { s += 20 }
177 + if line.range(of: "software", options: .caseInsensitive) != nil { s += 30 }
178 + return s + max(40 - line.count, 0)
179 + }
180 +
181 + private static func cleanCompany(_ line: String?) -> String? {
182 + guard let line, line.isNotBlank else { return nil }
183 + return whitespaceRun.replace(companySeparators.replace(line, with: " "), with: " ")
184 + .trimmingCharacters(in: .whitespacesAndNewlines)
185 + .nilIfBlank
186 + }
187 +
188 + private static func cleanJob(_ line: String?) -> String? {
189 + guard let line else { return nil }
190 + return whitespaceRun.replace(line, with: " ")
191 + .trimmingCharacters(in: .whitespacesAndNewlines)
192 + .nilIfBlank
193 + }
194 +
195 + private static func cleanAddress(_ line: String?) -> String? {
196 + guard let line else { return nil }
197 + return whitespaceRun.replace(line, with: " ")
198 + .trimmingCharacters(in: .whitespacesAndNewlines)
199 + .nilIfBlank
200 + }
201 +
202 + static func companyFromEmailDomain(_ email: String) -> String? {
203 + let afterAt: Substring
204 + if let atIdx = email.firstIndex(of: "@") {
205 + afterAt = email[email.index(after: atIdx)...]
206 + } else {
207 + afterAt = ""
208 + }
209 + let domain = String(afterAt.prefix(while: { $0 != "." }))
210 + if domain.count < 3 { return nil }
211 + // dimosoftware → Dimo Software (best-effort)
212 + let spaced = camelBoundary.replace(domain, with: " ")
213 + .replacingOccurrences(of: "software", with: " Software", options: .caseInsensitive)
214 + .replacingOccurrences(of: "solutions", with: " Solutions", options: .caseInsensitive)
215 + .trimmingCharacters(in: .whitespacesAndNewlines)
216 + guard let f = spaced.first else { return nil }
217 + return (String(f).uppercased() + spaced.dropFirst()).nilIfBlank
218 + }
219 +}
A ios/Card2vcf/Contact/KotlinCompat.swift
+75 -0
@@ -0,0 +1,75 @@
1 +import Foundation
2 +
3 +// Small Kotlin-stdlib compatibility helpers shared by the ported domain code.
4 +
5 +extension String {
6 + /// Kotlin `isBlank()`.
7 + var isBlank: Bool { allSatisfy { $0.isWhitespace } }
8 +
9 + /// Kotlin `isNotBlank()`.
10 + var isNotBlank: Bool { !isBlank }
11 +
12 + /// Kotlin `takeIf { it.isNotBlank() }` / `ifBlank { null }`.
13 + var nilIfBlank: String? { isBlank ? nil : self }
14 +
15 + /// Kotlin `String.lines()` — splits on \r\n, \n and \r.
16 + func kotlinLines() -> [String] {
17 + replacingOccurrences(of: "\r\n", with: "\n")
18 + .replacingOccurrences(of: "\r", with: "\n")
19 + .components(separatedBy: "\n")
20 + }
21 +}
22 +
23 +extension Array where Element: Hashable {
24 + /// Kotlin `distinct()` — keeps the first occurrence, preserves order.
25 + func distinctPreservingOrder() -> [Element] {
26 + var seen = Set<Element>()
27 + return filter { seen.insert($0).inserted }
28 + }
29 +}
30 +
31 +/// Thin NSRegularExpression wrapper mirroring the Kotlin `Regex` call sites.
32 +final class KotlinRegex {
33 + private let regex: NSRegularExpression
34 +
35 + /// Patterns are compile-time constants ported from Kotlin; a parse failure is a programmer error.
36 + init(_ pattern: String, options: NSRegularExpression.Options = []) {
37 + // swiftlint:disable:next force_try
38 + regex = try! NSRegularExpression(pattern: pattern, options: options)
39 + }
40 +
41 + /// Kotlin `containsMatchIn`.
42 + func containsMatchIn(_ s: String) -> Bool {
43 + regex.firstMatch(in: s, options: [], range: NSRange(s.startIndex..., in: s)) != nil
44 + }
45 +
46 + /// Kotlin `findAll(...).map { it.value }`.
47 + func findAll(_ s: String) -> [String] {
48 + regex.matches(in: s, options: [], range: NSRange(s.startIndex..., in: s)).compactMap {
49 + Range($0.range, in: s).map { String(s[$0]) }
50 + }
51 + }
52 +
53 + /// Kotlin `String.replace(regex, replacement)` — literal replacement string.
54 + func replace(_ s: String, with replacement: String) -> String {
55 + regex.stringByReplacingMatches(
56 + in: s,
57 + options: [],
58 + range: NSRange(s.startIndex..., in: s),
59 + withTemplate: NSRegularExpression.escapedTemplate(for: replacement)
60 + )
61 + }
62 +
63 + /// Kotlin `Regex.split` — segments between matches, including empty ones.
64 + func split(_ s: String) -> [String] {
65 + var result: [String] = []
66 + var lastEnd = s.startIndex
67 + for m in regex.matches(in: s, options: [], range: NSRange(s.startIndex..., in: s)) {
68 + guard let r = Range(m.range, in: s) else { continue }
69 + result.append(String(s[lastEnd..<r.lowerBound]))
70 + lastEnd = r.upperBound
71 + }
72 + result.append(String(s[lastEnd...]))
73 + return result
74 + }
75 +}
A ios/Card2vcf/Contact/VCardParser.swift
+173 -0
@@ -0,0 +1,173 @@
1 +import Foundation
2 +
3 +/// vCard 3.0 / 4.0 import.
4 +enum VCardParser {
5 + static func parse(_ text: String) -> [ContactCard] {
6 + let unfolded = unfold(text)
7 + var cards: [ContactCard] = []
8 + var buf: [String] = []
9 + for line in unfolded.components(separatedBy: "\n") {
10 + let t = trimEnd(line)
11 + if equalsIgnoreCase(t, "BEGIN:VCARD") {
12 + buf = []
13 + } else if equalsIgnoreCase(t, "END:VCARD") {
14 + cards.append(parseOne(buf))
15 + } else if !t.isEmpty {
16 + buf.append(t)
17 + }
18 + }
19 + return cards
20 + }
21 +
22 + private static func equalsIgnoreCase(_ a: String, _ b: String) -> Bool {
23 + a.compare(b, options: .caseInsensitive) == .orderedSame
24 + }
25 +
26 + /// Kotlin `trimEnd()` — strips trailing whitespace.
27 + private static func trimEnd(_ s: String) -> String {
28 + var t = s
29 + while let last = t.last, last.isWhitespace { t.removeLast() }
30 + return t
31 + }
32 +
33 + private static func unfold(_ text: String) -> String {
34 + var out = ""
35 + let normalized = text
36 + .replacingOccurrences(of: "\r\n", with: "\n")
37 + .replacingOccurrences(of: "\r", with: "\n")
38 + for line in normalized.components(separatedBy: "\n") {
39 + if line.hasPrefix(" ") || line.hasPrefix("\t") {
40 + out += line.dropFirst()
41 + } else {
42 + if !out.isEmpty { out += "\n" }
43 + out += line
44 + }
45 + }
46 + return out
47 + }
48 +
49 + private static func parseOne(_ lines: [String]) -> ContactCard {
50 + var fullName: String? = nil
51 + var firstName: String? = nil
52 + var lastName: String? = nil
53 + var company: String? = nil
54 + var jobTitle: String? = nil
55 + var phones: [String] = []
56 + var emails: [String] = []
57 + var website: String? = nil
58 + var address: String? = nil
59 + var note: String? = nil
60 +
61 + for line in lines {
62 + guard let colon = line.firstIndex(of: ":") else { continue }
63 + let nameAndParams = String(line[..<colon])
64 + let rawValue = String(line[line.index(after: colon)...])
65 + let prop = propertyName(nameAndParams).uppercased()
66 + if prop == "PHOTO" || prop == "VERSION" || prop == "BEGIN" {
67 + continue
68 + }
69 + let value = unescape(rawValue)
70 + switch prop {
71 + case "FN":
72 + fullName = value.nilIfBlank
73 + case "N":
74 + let parts = splitUnescaped(rawValue)
75 + lastName = unescape(parts.count > 0 ? parts[0] : "").nilIfBlank
76 + firstName = unescape(parts.count > 1 ? parts[1] : "").nilIfBlank
77 + case "ORG":
78 + company = value.nilIfBlank
79 + case "TITLE":
80 + jobTitle = value.nilIfBlank
81 + case "TEL":
82 + if let v = value.nilIfBlank { phones.append(v) }
83 + case "EMAIL":
84 + if let v = value.nilIfBlank { emails.append(v) }
85 + case "URL":
86 + website = value.nilIfBlank
87 + case "ADR":
88 + let parts = splitUnescaped(rawValue)
89 + .map { unescape($0).trimmingCharacters(in: .whitespacesAndNewlines) }
90 + .filter { !$0.isEmpty }
91 + address = parts.joined(separator: ", ").nilIfBlank
92 + case "NOTE":
93 + note = value.nilIfBlank
94 + default:
95 + break
96 + }
97 + }
98 +
99 + return ContactCard(
100 + fullName: fullName,
101 + firstName: firstName,
102 + lastName: lastName,
103 + company: company,
104 + jobTitle: jobTitle,
105 + phones: phones,
106 + emails: emails,
107 + website: website,
108 + address: address,
109 + note: note
110 + )
111 + }
112 +
113 + /// Property name before ':' : strip group prefix (item1.), take part before first ';'.
114 + private static func propertyName(_ nameAndParams: String) -> String {
115 + let afterGroup: String
116 + if let dot = nameAndParams.lastIndex(of: ".") {
117 + afterGroup = String(nameAndParams[nameAndParams.index(after: dot)...])
118 + } else {
119 + afterGroup = nameAndParams
120 + }
121 + return String(afterGroup.prefix(while: { $0 != ";" }))
122 + }
123 +
124 + private static func unescape(_ value: String) -> String {
125 + var out = ""
126 + let chars = Array(value)
127 + var i = 0
128 + while i < chars.count {
129 + let c = chars[i]
130 + if c == "\\" && i + 1 < chars.count {
131 + let n = chars[i + 1]
132 + switch n {
133 + case "n", "N":
134 + out.append("\n")
135 + case "r", "R":
136 + break // skip CR
137 + default:
138 + out.append(n) // covers ';' ',' '\\' and any other escaped char
139 + }
140 + i += 2
141 + } else {
142 + out.append(c)
143 + i += 1
144 + }
145 + }
146 + return out
147 + }
148 +
149 + /// Split on unescaped ';' (vCard structured values).
150 + private static func splitUnescaped(_ value: String) -> [String] {
151 + var parts: [String] = []
152 + var buf = ""
153 + let chars = Array(value)
154 + var i = 0
155 + while i < chars.count {
156 + let c = chars[i]
157 + if c == "\\" && i + 1 < chars.count {
158 + buf.append(c)
159 + buf.append(chars[i + 1])
160 + i += 2
161 + } else if c == ";" {
162 + parts.append(buf)
163 + buf = ""
164 + i += 1
165 + } else {
166 + buf.append(c)
167 + i += 1
168 + }
169 + }
170 + parts.append(buf)
171 + return parts
172 + }
173 +}
A ios/Card2vcf/Contact/VCardSerializer.swift
+33 -0
@@ -0,0 +1,33 @@
1 +import Foundation
2 +
3 +/// vCard 3.0 export.
4 +enum VCardSerializer {
5 + static func toVCard3(_ card: ContactCard) -> String {
6 + var lines: [String] = ["BEGIN:VCARD", "VERSION:3.0"]
7 + if let v = card.fullName?.nilIfBlank { lines.append("FN:\(escape(v))") }
8 + if let n = buildNField(card) { lines.append("N:\(n)") }
9 + if let v = card.company?.nilIfBlank { lines.append("ORG:\(escape(v))") }
10 + if let v = card.jobTitle?.nilIfBlank { lines.append("TITLE:\(escape(v))") }
11 + for phone in card.phones { lines.append("TEL;TYPE=CELL:\(escape(phone))") }
12 + for email in card.emails { lines.append("EMAIL;TYPE=INTERNET:\(escape(email))") }
13 + if let v = card.website?.nilIfBlank { lines.append("URL:\(escape(v))") }
14 + if let v = card.address?.nilIfBlank { lines.append("ADR;TYPE=WORK:;;\(escape(v));;;;") }
15 + if let v = card.note?.nilIfBlank { lines.append("NOTE:\(escape(v))") }
16 + lines.append("END:VCARD")
17 + return lines.joined(separator: "\n")
18 + }
19 +
20 + private static func buildNField(_ card: ContactCard) -> String? {
21 + guard let last = card.lastName?.nilIfBlank else { return nil }
22 + let first = card.firstName ?? ""
23 + return "\(escape(last));\(escape(first));;;"
24 + }
25 +
26 + static func escape(_ value: String) -> String {
27 + value.replacingOccurrences(of: "\\", with: "\\\\")
28 + .replacingOccurrences(of: ";", with: "\\;")
29 + .replacingOccurrences(of: ",", with: "\\,")
30 + .replacingOccurrences(of: "\n", with: "\\n")
31 + .replacingOccurrences(of: "\r", with: "")
32 + }
33 +}
A ios/Card2vcf/Data/ContactImageStore.swift
+67 -0
@@ -0,0 +1,67 @@
1 +import UIKit
2 +
3 +struct ImageStoreError: Error, CustomStringConvertible {
4 + let message: String
5 + var description: String { "ImageStoreError: \(message)" }
6 +}
7 +
8 +/// Port of android `data/ContactImageStore.kt`.
9 +/// Layout: <Application Support>/contacts/<contactId>/<name> (Android used
10 +/// filesDir with the same relative scheme).
11 +final class ContactImageStore: ContactImageStoring {
12 + private let root: URL
13 +
14 + init(rootDirectory: URL? = nil) {
15 + if let rootDirectory {
16 + root = rootDirectory
17 + } else {
18 + let base = FileManager.default.urls(
19 + for: .applicationSupportDirectory,
20 + in: .userDomainMask
21 + )[0]
22 + root = base.appendingPathComponent("contacts", isDirectory: true)
23 + }
24 + }
25 +
26 + private func dir(_ id: Int64) -> URL {
27 + let url = root.appendingPathComponent(String(id), isDirectory: true)
28 + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
29 + return url
30 + }
31 +
32 + @discardableResult
33 + func saveJpeg(contactId: Int64, name: String, image: UIImage, quality: CGFloat = 0.9) throws -> String {
34 + guard let data = image.jpegData(compressionQuality: quality) else {
35 + throw ImageStoreError(message: "JPEG encoding failed for \(name)")
36 + }
37 + return try saveBytes(contactId: contactId, name: name, bytes: data)
38 + }
39 +
40 + /// Writes raw bytes (e.g. an image downloaded from the server).
41 + @discardableResult
42 + func saveBytes(contactId: Int64, name: String, bytes: Data) throws -> String {
43 + let file = dir(contactId).appendingPathComponent(name)
44 + try bytes.write(to: file, options: .atomic)
45 + return file.path
46 + }
47 +
48 + func deleteAll(contactId: Int64) {
49 + try? FileManager.default.removeItem(
50 + at: root.appendingPathComponent(String(contactId), isDirectory: true)
51 + )
52 + }
53 +
54 + /// Copies re-encoding after EXIF correction, so the stored file is already
55 + /// upright (Kotlin `copyImage`).
56 + func copyImage(fromPath: String?, toContactId: Int64, name: String) -> String? {
57 + guard let fromPath, fromPath.isNotBlank else { return nil }
58 + let src = URL(fileURLWithPath: fromPath)
59 + var isDirectory: ObjCBool = false
60 + guard FileManager.default.fileExists(atPath: src.path, isDirectory: &isDirectory),
61 + !isDirectory.boolValue else {
62 + return nil
63 + }
64 + guard let oriented = ImageOrientation.fromFile(src)?.bitmap else { return nil }
65 + return try? saveJpeg(contactId: toContactId, name: name, image: oriented)
66 + }
67 +}
A ios/Card2vcf/Data/ContactRepository.swift
+332 -0
@@ -0,0 +1,332 @@
1 +import Foundation
2 +
3 +struct VcfImportResult: Equatable {
4 + var imported: Int
5 + var skippedDuplicates: Int
6 + var errors: Int
7 +}
8 +
9 +/// Image-file abstraction for the repository (Android used `ContactImageStore`
10 +/// with `Bitmap`s; the iOS implementation lives with the Scan module owner).
11 +/// Bitmap-saving paths were replaced by pre-saved file paths in the API below.
12 +protocol ContactImageStoring {
13 + /// Copies (re-encoding if needed) an existing image into the target contact's
14 + /// storage. Returns the new path, or nil when the source is missing/unreadable.
15 + func copyImage(fromPath: String?, toContactId: Int64, name: String) -> String?
16 +
17 + /// Deletes every stored image of the contact.
18 + func deleteAll(contactId: Int64)
19 +}
20 +
21 +/// Sync payload mapping (Android `ContactSyncMapper`; the iOS implementation
22 +/// lives in the Sync module and should conform to this protocol).
23 +protocol ContactSyncPayloadMapping {
24 + func toCreatePayload(_ entity: CrmContactEntity) -> String
25 + func toUpdatePayload(_ entity: CrmContactEntity) -> String
26 +}
27 +
28 +final class ContactRepository {
29 + private let dao: CrmContactDao
30 + private let images: ContactImageStoring?
31 + private let syncOpDao: SyncOpDao?
32 + private let syncMapper: ContactSyncPayloadMapping?
33 + private let isSyncEnabled: () -> Bool
34 +
35 + init(
36 + dao: CrmContactDao,
37 + images: ContactImageStoring? = nil,
38 + syncOpDao: SyncOpDao? = nil,
39 + syncMapper: ContactSyncPayloadMapping? = nil,
40 + isSyncEnabled: @escaping () -> Bool = { false }
41 + ) {
42 + self.dao = dao
43 + self.images = images
44 + self.syncOpDao = syncOpDao
45 + self.syncMapper = syncMapper
46 + self.isSyncEnabled = isSyncEnabled
47 + }
48 +
49 + static func currentMillis() -> Int64 {
50 + Int64(Date().timeIntervalSince1970 * 1000)
51 + }
52 +
53 + /// Kotlin `observeAll(): Flow` — manual refresh model on iOS.
54 + func fetchAll() async throws -> [CrmContactEntity] {
55 + try await dao.fetchAll()
56 + }
57 +
58 + func getById(_ id: Int64) async throws -> CrmContactEntity? {
59 + try await dao.getById(id)
60 + }
61 +
62 + func getExclusions() async throws -> [DuplicateExclusionEntity] {
63 + try await dao.getExclusions()
64 + }
65 +
66 + func search(_ query: String) async throws -> [CrmContactEntity] {
67 + let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
68 + if q.isEmpty { return try await dao.getAll() }
69 + // Alphanumeric tokens only — avoids FTS MATCH crashes on `"` `*` `:` etc.
70 + let tokens = q.components(separatedBy: .whitespacesAndNewlines)
71 + .map { token in String(token.filter { $0.isLetter || $0.isNumber }) }
72 + .filter { !$0.isEmpty }
73 + if tokens.isEmpty { return [] }
74 + let token = tokens.map { "\($0)*" }.joined(separator: " ")
75 + let ids = try await dao.searchIds(token)
76 + var out: [CrmContactEntity] = []
77 + for id in ids {
78 + if let entity = try await dao.getById(id) { out.append(entity) }
79 + }
80 + return out
81 + }
82 +
83 + /// Android saved `Bitmap`s through `ContactImageStore`; on iOS the caller
84 + /// saves the files first and passes the resulting paths.
85 + /// `notesOverride` mirrors Kotlin's `notesOverride: String? = card.note`:
86 + /// omit it to use `card.note`, pass `.some(nil)` to explicitly clear notes.
87 + @discardableResult
88 + func insertFromCard(
89 + _ card: ContactCard,
90 + cardImagePath: String? = nil,
91 + profileImagePath: String? = nil,
92 + notesOverride: String?? = nil,
93 + now: Int64 = ContactRepository.currentMillis()
94 + ) async throws -> Int64 {
95 + let notes = notesOverride ?? card.note
96 + let entity = card.toEntity(notes: notes, now: now)
97 + let id = try await dao.insert(entity)
98 + var inserted = entity
99 + inserted.id = id
100 + var updated = inserted
101 + if let cardImagePath {
102 + updated.cardImagePath = cardImagePath
103 + }
104 + if let profileImagePath {
105 + updated.profileImagePath = profileImagePath
106 + }
107 + if updated != inserted {
108 + try await dao.update(updated)
109 + }
110 + if let stored = try await dao.getById(id) {
111 + try await enqueueContactCreate(stored)
112 + }
113 + return id
114 + }
115 +
116 + func updateFromCard(
117 + id: Int64,
118 + card: ContactCard,
119 + cardImagePath: String? = nil,
120 + profileImagePath: String? = nil,
121 + preserveNotesIfBlankDraft: Bool = true,
122 + now: Int64 = ContactRepository.currentMillis()
123 + ) async throws {
124 + guard let existing = try await dao.getById(id) else { return }
125 + let notes: String?
126 + if preserveNotesIfBlankDraft && (card.note?.isBlank ?? true) {
127 + notes = existing.notes
128 + } else {
129 + notes = card.note
130 + }
131 + var updated = card.toEntity(notes: notes, now: now)
132 + updated.id = id
133 + updated.createdAt = existing.createdAt
134 + updated.cardImagePath = existing.cardImagePath
135 + updated.profileImagePath = existing.profileImagePath
136 + if let cardImagePath {
137 + updated.cardImagePath = cardImagePath
138 + }
139 + if let profileImagePath {
140 + updated.profileImagePath = profileImagePath
141 + }
142 + try await dao.update(updated)
143 + if let stored = try await dao.getById(id) {
144 + try await enqueueContactUpdate(stored)
145 + }
146 + }
147 +
148 + func delete(_ id: Int64) async throws {
149 + let existing = try await dao.getById(id)
150 + try await dao.deleteExclusionsFor(id)
151 + try await dao.deleteById(id)
152 + images?.deleteAll(contactId: id)
153 + if let existing {
154 + try await enqueueContactDelete(existing)
155 + }
156 + }
157 +
158 + // Kotlin `rotateCardImage(id:degrees:)` is not ported: it re-encoded the
159 + // card JPEG with Android Bitmap/Matrix. On iOS the image rotation belongs
160 + // to the image-store/Scan module owner.
161 +
162 + func markDifferent(_ a: Int64, _ b: Int64) async throws {
163 + let lo = min(a, b)
164 + let hi = max(a, b)
165 + try await dao.insertExclusion(DuplicateExclusionEntity(idA: lo, idB: hi))
166 + }
167 +
168 + func mergeContacts(
169 + sources: [CrmContactEntity],
170 + choices: MergeFieldChoices
171 + ) async throws {
172 + let map = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
173 + var merged = ContactMerge.merge(sources: sources, choices: choices)
174 + let keepId = choices.keepId
175 + if let srcId = choices.cardImageFromId {
176 + let path = map[srcId]?.cardImagePath
177 + if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "card.jpg") {
178 + merged.cardImagePath = copied
179 + }
180 + }
181 + if let srcId = choices.profileImageFromId {
182 + let path = map[srcId]?.profileImagePath
183 + if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "profile.jpg") {
184 + merged.profileImagePath = copied
185 + }
186 + }
187 + try await dao.update(merged)
188 + for s in sources where s.id != keepId {
189 + try await delete(s.id)
190 + }
191 + }
192 +
193 + func findMatchingId(card: ContactCard, among: [CrmContactEntity]) -> Int64? {
194 + let emails = Set(card.emails.map(ContactNormalizer.email).filter { !$0.isEmpty })
195 + let phones = Set(card.phones.map(ContactNormalizer.phone).filter { !$0.isEmpty })
196 + for e in among {
197 + let eEmails = Set(e.emails.map(ContactNormalizer.email))
198 + let ePhones = Set(e.phones.map(ContactNormalizer.phone))
199 + if !emails.isDisjoint(with: eEmails) || !phones.isDisjoint(with: ePhones) {
200 + return e.id
201 + }
202 + }
203 + return nil
204 + }
205 +
206 + func importCards(_ cards: [ContactCard]) async throws -> VcfImportResult {
207 + var imported = 0
208 + var skipped = 0
209 + var errors = 0
210 + var existing = try await dao.getAll()
211 + for card in cards {
212 + do {
213 + if !card.hasAnyField() {
214 + errors += 1
215 + continue
216 + }
217 + if findMatchingId(card: card, among: existing) != nil {
218 + skipped += 1
219 + continue
220 + }
221 + let id = try await insertFromCard(card)
222 + if let stored = try await dao.getById(id) {
223 + existing.append(stored)
224 + }
225 + imported += 1
226 + } catch {
227 + errors += 1
228 + }
229 + }
230 + return VcfImportResult(imported: imported, skippedDuplicates: skipped, errors: errors)
231 + }
232 +
233 + private func enqueueContactCreate(_ contact: CrmContactEntity) async throws {
234 + guard isSyncEnabled(), let syncOpDao, let syncMapper else { return }
235 + try await syncOpDao.insert(
236 + SyncOpEntity(
237 + entityType: "contact",
238 + op: "create",
239 + payloadJson: syncMapper.toCreatePayload(contact),
240 + localId: contact.id,
241 + createdAt: ContactRepository.currentMillis()
242 + )
243 + )
244 + }
245 +
246 + /// If not yet synchronized, patches the pending create (like kanban tasks).
247 + private func enqueueContactUpdate(_ contact: CrmContactEntity) async throws {
248 + guard isSyncEnabled(), let syncOpDao, let syncMapper else { return }
249 + if let serverId = contact.serverId {
250 + try await syncOpDao.insert(
251 + SyncOpEntity(
252 + entityType: "contact",
253 + op: "update",
254 + payloadJson: syncMapper.toUpdatePayload(contact),
255 + serverId: serverId,
256 + createdAt: ContactRepository.currentMillis()
257 + )
258 + )
259 + return
260 + }
261 + let pendingCreate = try await syncOpDao.listAll()
262 + .first { $0.entityType == "contact" && $0.op == "create" && $0.localId == contact.id }
263 + if var patched = pendingCreate {
264 + patched.payloadJson = syncMapper.toCreatePayload(contact)
265 + try await syncOpDao.insert(patched)
266 + }
267 + }
268 +
269 + private func enqueueContactDelete(_ contact: CrmContactEntity) async throws {
270 + // Kotlin only required the op queue here (deletes carry no payload).
271 + guard isSyncEnabled(), let syncOpDao else { return }
272 + if let serverId = contact.serverId {
273 + try await syncOpDao.insert(
274 + SyncOpEntity(
275 + entityType: "contact",
276 + op: "delete",
277 + serverId: serverId,
278 + createdAt: ContactRepository.currentMillis()
279 + )
280 + )
281 + } else {
282 + try await clearPendingContactOps(contact.id)
283 + }
284 + }
285 +
286 + private func clearPendingContactOps(_ localId: Int64) async throws {
287 + guard let syncOpDao else { return }
288 + let ops = try await syncOpDao.listAll()
289 + .filter { $0.entityType == "contact" && $0.localId == localId }
290 + for op in ops {
291 + try await syncOpDao.deleteById(op.id)
292 + }
293 + }
294 +}
295 +
296 +extension ContactCard {
297 + func toEntity(notes: String?, now: Int64) -> CrmContactEntity {
298 + CrmContactEntity(
299 + fullName: fullName,
300 + firstName: firstName,
301 + lastName: lastName,
302 + company: company,
303 + jobTitle: jobTitle,
304 + phones: phones,
305 + emails: emails,
306 + website: website,
307 + address: address,
308 + notes: notes,
309 + createdAt: now,
310 + updatedAt: now,
311 + phonesText: phones.joined(separator: " "),
312 + emailsText: emails.joined(separator: " ")
313 + )
314 + }
315 +}
316 +
317 +extension CrmContactEntity {
318 + func toCard() -> ContactCard {
319 + ContactCard(
320 + fullName: fullName,
321 + firstName: firstName,
322 + lastName: lastName,
323 + company: company,
324 + jobTitle: jobTitle,
325 + phones: phones,
326 + emails: emails,
327 + website: website,
328 + address: address,
329 + note: notes
330 + )
331 + }
332 +}
A ios/Card2vcf/Data/Converters.swift
+23 -0
@@ -0,0 +1,23 @@
1 +import Foundation
2 +
3 +/// Room `TypeConverter` port: `[String]` ⇄ JSON array string.
4 +struct Converters {
5 + func fromStringList(_ value: [String]?) -> String {
6 + let list = value ?? []
7 + guard let data = try? JSONSerialization.data(withJSONObject: list, options: []),
8 + let json = String(data: data, encoding: .utf8) else {
9 + return "[]"
10 + }
11 + return json
12 + }
13 +
14 + func toStringList(_ value: String?) -> [String] {
15 + guard let value, value.isNotBlank else { return [] }
16 + // Kotlin threw JSONException on malformed input; here malformed input decodes to [].
17 + guard let data = value.data(using: .utf8),
18 + let arr = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
19 + return []
20 + }
21 + return arr.compactMap { $0 as? String }
22 + }
23 +}
A ios/Card2vcf/Data/CrmContactDao.swift
+44 -0
@@ -0,0 +1,44 @@
1 +import Foundation
2 +
3 +/// Port of the Room `CrmContactDao`. SQL semantics of each Kotlin `@Query` are
4 +/// documented so the persistence implementation can reproduce them.
5 +protocol CrmContactDao {
6 + /// INSERT OR REPLACE INTO crm_contacts; returns the row id.
7 + @discardableResult
8 + func insert(_ entity: CrmContactEntity) async throws -> Int64
9 +
10 + /// UPDATE by primary key.
11 + func update(_ entity: CrmContactEntity) async throws
12 +
13 + /// DELETE FROM crm_contacts WHERE id = :id
14 + func deleteById(_ id: Int64) async throws
15 +
16 + /// SELECT * FROM crm_contacts WHERE id = :id
17 + func getById(_ id: Int64) async throws -> CrmContactEntity?
18 +
19 + /// SELECT * FROM crm_contacts WHERE serverId = :serverId
20 + func getByServerId(_ serverId: String) async throws -> CrmContactEntity?
21 +
22 + /// DELETE FROM crm_contacts WHERE serverId = :serverId
23 + func deleteByServerId(_ serverId: String) async throws
24 +
25 + /// Kotlin `observeAll(): Flow<List<CrmContactEntity>>` — manual refresh model.
26 + /// SELECT * FROM crm_contacts ORDER BY createdAt DESC
27 + func fetchAll() async throws -> [CrmContactEntity]
28 +
29 + /// SELECT * FROM crm_contacts ORDER BY createdAt DESC
30 + func getAll() async throws -> [CrmContactEntity]
31 +
32 + /// SELECT rowid FROM crm_contacts_fts WHERE crm_contacts_fts MATCH :query
33 + func searchIds(_ query: String) async throws -> [Int64]
34 +
35 + /// INSERT OR IGNORE INTO duplicate_exclusions; returns the row id (-1 when ignored).
36 + @discardableResult
37 + func insertExclusion(_ ex: DuplicateExclusionEntity) async throws -> Int64
38 +
39 + /// SELECT * FROM duplicate_exclusions
40 + func getExclusions() async throws -> [DuplicateExclusionEntity]
41 +
42 + /// DELETE FROM duplicate_exclusions WHERE idA = :id OR idB = :id
43 + func deleteExclusionsFor(_ id: Int64) async throws
44 +}
A ios/Card2vcf/Data/CrmContactEntity.swift
+27 -0
@@ -0,0 +1,27 @@
1 +import Foundation
2 +
3 +/// Room entity `crm_contacts` (id is the auto-generated primary key).
4 +struct CrmContactEntity: Equatable {
5 + var id: Int64 = 0
6 + var fullName: String? = nil
7 + var firstName: String? = nil
8 + var lastName: String? = nil
9 + var company: String? = nil
10 + var jobTitle: String? = nil
11 + var phones: [String] = []
12 + var emails: [String] = []
13 + var website: String? = nil
14 + var address: String? = nil
15 + var notes: String? = nil
16 + var cardImagePath: String? = nil
17 + var profileImagePath: String? = nil
18 + var createdAt: Int64 = 0
19 + var updatedAt: Int64 = 0
20 + var phonesText: String = "" // space-joined, for FTS
21 + var emailsText: String = ""
22 + var serverId: String? = nil
23 + var statut: String? = nil
24 + var etape: String? = nil
25 + var tags: [String] = []
26 + var entrepriseServerId: String? = nil
27 +}
A ios/Card2vcf/Data/CrmContactFts.swift
+15 -0
@@ -0,0 +1,15 @@
1 +import Foundation
2 +
3 +/// Room FTS4 mirror of `CrmContactEntity` (table `crm_contacts_fts`).
4 +struct CrmContactFts: Equatable {
5 + var fullName: String?
6 + var firstName: String?
7 + var lastName: String?
8 + var company: String?
9 + var jobTitle: String?
10 + var website: String?
11 + var address: String?
12 + var notes: String?
13 + var phonesText: String?
14 + var emailsText: String?
15 +}
A ios/Card2vcf/Data/DuplicateExclusionEntity.swift
+8 -0
@@ -0,0 +1,8 @@
1 +import Foundation
2 +
3 +/// Room entity `duplicate_exclusions` (unique index on (idA, idB)).
4 +struct DuplicateExclusionEntity: Equatable {
5 + var id: Int64 = 0
6 + var idA: Int64 // always idA < idB
7 + var idB: Int64
8 +}
A ios/Card2vcf/Data/EntrepriseDao.swift
+20 -0
@@ -0,0 +1,20 @@
1 +import Foundation
2 +
3 +/// Port of the Room `EntrepriseDao`.
4 +protocol EntrepriseDao {
5 + /// INSERT OR REPLACE INTO entreprises; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: EntrepriseEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM entreprises WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> EntrepriseEntity?
11 +
12 + /// SELECT * FROM entreprises WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> EntrepriseEntity?
14 +
15 + /// DELETE FROM entreprises WHERE serverId = :serverId
16 + func deleteByServerId(_ serverId: String) async throws
17 +
18 + /// SELECT * FROM entreprises
19 + func listAll() async throws -> [EntrepriseEntity]
20 +}
A ios/Card2vcf/Data/EntrepriseEntity.swift
+12 -0
@@ -0,0 +1,12 @@
1 +import Foundation
2 +
3 +/// Room entity `entreprises`.
4 +struct EntrepriseEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var nom: String = ""
8 + var secteur: String = ""
9 + var creePar: String = ""
10 + var createdAt: Int64 = 0
11 + var updatedAt: Int64 = 0
12 +}
A ios/Card2vcf/Data/IndisponibiliteDao.swift
+20 -0
@@ -0,0 +1,20 @@
1 +import Foundation
2 +
3 +/// Port of the Room `IndisponibiliteDao`.
4 +protocol IndisponibiliteDao {
5 + /// INSERT OR REPLACE INTO indisponibilites; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: IndisponibiliteEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM indisponibilites WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> IndisponibiliteEntity?
11 +
12 + /// DELETE FROM indisponibilites WHERE serverId = :serverId
13 + func deleteByServerId(_ serverId: String) async throws
14 +
15 + /// SELECT * FROM indisponibilites WHERE cibleType = :cibleType AND cibleId = :cibleId
16 + func listByCible(cibleType: String, cibleId: String) async throws -> [IndisponibiliteEntity]
17 +
18 + /// SELECT * FROM indisponibilites
19 + func listAll() async throws -> [IndisponibiliteEntity]
20 +}
A ios/Card2vcf/Data/IndisponibiliteEntity.swift
+16 -0
@@ -0,0 +1,16 @@
1 +import Foundation
2 +
3 +/// Read-only mirror; no local push (unavailabilities are managed server-side).
4 +/// Room entity `indisponibilites`.
5 +struct IndisponibiliteEntity: Equatable {
6 + var localId: Int64 = 0
7 + var serverId: String? = nil
8 + var cibleType: String = ""
9 + var cibleId: String = ""
10 + var debutMs: Int64 = 0
11 + var finMs: Int64 = 0
12 + var nature: String? = nil
13 + var commentaire: String? = nil
14 + var updatedAt: Int64 = 0
15 + var calendarEventId: Int64? = nil
16 +}
A ios/Card2vcf/Data/InteractionDao.swift
+24 -0
@@ -0,0 +1,24 @@
1 +import Foundation
2 +
3 +/// Port of the Room `InteractionDao`.
4 +protocol InteractionDao {
5 + /// INSERT OR REPLACE INTO interactions; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: InteractionEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM interactions WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> InteractionEntity?
11 +
12 + /// DELETE FROM interactions WHERE serverId = :serverId
13 + func deleteByServerId(_ serverId: String) async throws
14 +
15 + /// SELECT * FROM interactions WHERE contactServerId = :contactServerId
16 + func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity]
17 +
18 + /// Kotlin `observeByContactServerId(...): Flow<List<InteractionEntity>>` — manual refresh model.
19 + /// SELECT * FROM interactions WHERE contactServerId = :contactServerId ORDER BY createdAt DESC
20 + func fetchByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity]
21 +
22 + /// SELECT * FROM interactions
23 + func listAll() async throws -> [InteractionEntity]
24 +}
A ios/Card2vcf/Data/InteractionEntity.swift
+14 -0
@@ -0,0 +1,14 @@
1 +import Foundation
2 +
3 +/// Room entity `interactions`.
4 +struct InteractionEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var contactServerId: String = ""
8 + var type: String = "note"
9 + var sujet: String = ""
10 + var description: String = ""
11 + var creePar: String = ""
12 + var createdAt: Int64 = 0
13 + var updatedAt: Int64? = nil
14 +}
A ios/Card2vcf/Data/ProjetDao.swift
+24 -0
@@ -0,0 +1,24 @@
1 +import Foundation
2 +
3 +/// Port of the Room `ProjetDao`.
4 +protocol ProjetDao {
5 + /// INSERT OR REPLACE INTO projets; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: ProjetEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM projets WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> ProjetEntity?
11 +
12 + /// SELECT * FROM projets WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> ProjetEntity?
14 +
15 + /// DELETE FROM projets WHERE serverId = :serverId
16 + func deleteByServerId(_ serverId: String) async throws
17 +
18 + /// SELECT * FROM projets
19 + func listAll() async throws -> [ProjetEntity]
20 +
21 + /// Kotlin `observeAll(): Flow<List<ProjetEntity>>` — manual refresh model.
22 + /// SELECT * FROM projets ORDER BY nom ASC
23 + func fetchAll() async throws -> [ProjetEntity]
24 +}
A ios/Card2vcf/Data/ProjetEntity.swift
+14 -0
@@ -0,0 +1,14 @@
1 +import Foundation
2 +
3 +/// Room entity `projets`.
4 +struct ProjetEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var nom: String = ""
8 + var description: String = ""
9 + var workflowServerId: String = ""
10 + var membresJson: String = "[]"
11 + var creePar: String = ""
12 + var createdAt: Int64 = 0
13 + var updatedAt: Int64 = 0
14 +}
A ios/Card2vcf/Data/RdvDao.swift
+29 -0
@@ -0,0 +1,29 @@
1 +import Foundation
2 +
3 +/// Port of the Room `RdvDao`.
4 +protocol RdvDao {
5 + /// INSERT OR REPLACE INTO rdv; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: RdvEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM rdv WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> RdvEntity?
11 +
12 + /// SELECT * FROM rdv WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> RdvEntity?
14 +
15 + /// SELECT * FROM rdv WHERE calendarEventId = :calendarEventId
16 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> RdvEntity?
17 +
18 + /// SELECT * FROM rdv WHERE dirtyLocal = 1
19 + func listDirty() async throws -> [RdvEntity]
20 +
21 + /// DELETE FROM rdv WHERE serverId = :serverId
22 + func deleteByServerId(_ serverId: String) async throws
23 +
24 + /// DELETE FROM rdv WHERE localId = :localId
25 + func deleteByLocalId(_ localId: Int64) async throws
26 +
27 + /// SELECT * FROM rdv
28 + func listAll() async throws -> [RdvEntity]
29 +}
A ios/Card2vcf/Data/RdvEntity.swift
+17 -0
@@ -0,0 +1,17 @@
1 +import Foundation
2 +
3 +/// Room entity `rdv`.
4 +struct RdvEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var titre: String = ""
8 + var description: String = ""
9 + var lieu: String = ""
10 + var debutMs: Int64 = 0
11 + var finMs: Int64 = 0
12 + var contactIdsJson: String = "[]"
13 + var projetId: String? = nil
14 + var updatedAt: Int64 = 0
15 + var calendarEventId: Int64? = nil
16 + var dirtyLocal: Bool = false
17 +}
A ios/Card2vcf/Data/ReservationDao.swift
+32 -0
@@ -0,0 +1,32 @@
1 +import Foundation
2 +
3 +/// Port of the Room `ReservationDao`.
4 +protocol ReservationDao {
5 + /// INSERT OR REPLACE INTO reservations; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: ReservationEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM reservations WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> ReservationEntity?
11 +
12 + /// SELECT * FROM reservations WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> ReservationEntity?
14 +
15 + /// SELECT * FROM reservations WHERE calendarEventId = :calendarEventId
16 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> ReservationEntity?
17 +
18 + /// SELECT * FROM reservations WHERE dirtyLocal = 1
19 + func listDirty() async throws -> [ReservationEntity]
20 +
21 + /// DELETE FROM reservations WHERE serverId = :serverId
22 + func deleteByServerId(_ serverId: String) async throws
23 +
24 + /// DELETE FROM reservations WHERE localId = :localId
25 + func deleteByLocalId(_ localId: Int64) async throws
26 +
27 + /// SELECT * FROM reservations WHERE cibleType = :cibleType AND cibleId = :cibleId
28 + func listByCible(cibleType: String, cibleId: String) async throws -> [ReservationEntity]
29 +
30 + /// SELECT * FROM reservations
31 + func listAll() async throws -> [ReservationEntity]
32 +}
A ios/Card2vcf/Data/ReservationEntity.swift
+17 -0
@@ -0,0 +1,17 @@
1 +import Foundation
2 +
3 +/// Room entity `reservations`.
4 +struct ReservationEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var cibleType: String = ""
8 + var cibleId: String = ""
9 + var debutMs: Int64 = 0
10 + var finMs: Int64 = 0
11 + var motif: String = ""
12 + var statut: String = "active"
13 + var updatedAt: Int64 = 0
14 + var calendarEventId: Int64? = nil
15 + var dirtyLocal: Bool = false
16 + var conflictPending: Bool = false
17 +}
A ios/Card2vcf/Data/Sqlite/Card2vcfDatabase.swift
+37 -0
@@ -0,0 +1,37 @@
1 +import Foundation
2 +
3 +/// Facade over the SQLite persistence, mirroring Android's `CrmDatabase`.
4 +/// The underlying connection opens lazily on first query.
5 +final class Card2vcfDatabase {
6 + static let shared = Card2vcfDatabase()
7 +
8 + let sqlite: SqliteDatabase
9 +
10 + let crmContactDao: any CrmContactDao
11 + let entrepriseDao: any EntrepriseDao
12 + let projetDao: any ProjetDao
13 + let tacheDao: any TacheDao
14 + let interactionDao: any InteractionDao
15 + let workflowDao: any WorkflowDao
16 + let syncOpDao: any SyncOpDao
17 + let syncMetaDao: any SyncMetaDao
18 + let rdvDao: any RdvDao
19 + let reservationDao: any ReservationDao
20 + let indisponibiliteDao: any IndisponibiliteDao
21 +
22 + init(inMemory: Bool = false) {
23 + let db = SqliteDatabase(inMemory: inMemory)
24 + sqlite = db
25 + crmContactDao = SqliteCrmContactDao(db: db)
26 + entrepriseDao = SqliteEntrepriseDao(db: db)
27 + projetDao = SqliteProjetDao(db: db)
28 + tacheDao = SqliteTacheDao(db: db)
29 + interactionDao = SqliteInteractionDao(db: db)
30 + workflowDao = SqliteWorkflowDao(db: db)
31 + syncOpDao = SqliteSyncOpDao(db: db)
32 + syncMetaDao = SqliteSyncMetaDao(db: db)
33 + rdvDao = SqliteRdvDao(db: db)
34 + reservationDao = SqliteReservationDao(db: db)
35 + indisponibiliteDao = SqliteIndisponibiliteDao(db: db)
36 + }
37 +}
A ios/Card2vcf/Data/Sqlite/SqliteCrmContactDao.swift
+158 -0
@@ -0,0 +1,158 @@
1 +import Foundation
2 +
3 +struct SqliteCrmContactDao: CrmContactDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + id, fullName, firstName, lastName, company, jobTitle, phones, emails, \
8 + website, address, notes, cardImagePath, profileImagePath, createdAt, updatedAt, \
9 + phonesText, emailsText, serverId, statut, etape, tags, entrepriseServerId
10 + """
11 +
12 + private static func map(_ row: SqliteRow) -> CrmContactEntity {
13 + let converters = Converters()
14 + var e = CrmContactEntity()
15 + e.id = row.int64(0)
16 + e.fullName = row.textOrNil(1)
17 + e.firstName = row.textOrNil(2)
18 + e.lastName = row.textOrNil(3)
19 + e.company = row.textOrNil(4)
20 + e.jobTitle = row.textOrNil(5)
21 + e.phones = converters.toStringList(row.textOrNil(6))
22 + e.emails = converters.toStringList(row.textOrNil(7))
23 + e.website = row.textOrNil(8)
24 + e.address = row.textOrNil(9)
25 + e.notes = row.textOrNil(10)
26 + e.cardImagePath = row.textOrNil(11)
27 + e.profileImagePath = row.textOrNil(12)
28 + e.createdAt = row.int64(13)
29 + e.updatedAt = row.int64(14)
30 + e.phonesText = row.text(15)
31 + e.emailsText = row.text(16)
32 + e.serverId = row.textOrNil(17)
33 + e.statut = row.textOrNil(18)
34 + e.etape = row.textOrNil(19)
35 + e.tags = converters.toStringList(row.textOrNil(20))
36 + e.entrepriseServerId = row.textOrNil(21)
37 + return e
38 + }
39 +
40 + /// Bindings for every column except `id`.
41 + private static func bindings(_ e: CrmContactEntity) -> [SqliteValue] {
42 + let converters = Converters()
43 + return [
44 + .optText(e.fullName),
45 + .optText(e.firstName),
46 + .optText(e.lastName),
47 + .optText(e.company),
48 + .optText(e.jobTitle),
49 + .text(converters.fromStringList(e.phones)),
50 + .text(converters.fromStringList(e.emails)),
51 + .optText(e.website),
52 + .optText(e.address),
53 + .optText(e.notes),
54 + .optText(e.cardImagePath),
55 + .optText(e.profileImagePath),
56 + .int(e.createdAt),
57 + .int(e.updatedAt),
58 + .text(e.phonesText),
59 + .text(e.emailsText),
60 + .optText(e.serverId),
61 + .optText(e.statut),
62 + .optText(e.etape),
63 + .text(converters.fromStringList(e.tags)),
64 + .optText(e.entrepriseServerId),
65 + ]
66 + }
67 +
68 + @discardableResult
69 + func insert(_ entity: CrmContactEntity) async throws -> Int64 {
70 + try await db.insert(
71 + """
72 + INSERT OR REPLACE INTO crm_contacts (\(Self.columns))
73 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74 + """,
75 + [.rowId(entity.id)] + Self.bindings(entity)
76 + )
77 + }
78 +
79 + func update(_ entity: CrmContactEntity) async throws {
80 + try await db.write(
81 + """
82 + UPDATE crm_contacts SET
83 + fullName = ?, firstName = ?, lastName = ?, company = ?, jobTitle = ?,
84 + phones = ?, emails = ?, website = ?, address = ?, notes = ?,
85 + cardImagePath = ?, profileImagePath = ?, createdAt = ?, updatedAt = ?,
86 + phonesText = ?, emailsText = ?, serverId = ?, statut = ?, etape = ?,
87 + tags = ?, entrepriseServerId = ?
88 + WHERE id = ?
89 + """,
90 + Self.bindings(entity) + [.int(entity.id)]
91 + )
92 + }
93 +
94 + func deleteById(_ id: Int64) async throws {
95 + try await db.write("DELETE FROM crm_contacts WHERE id = ?", [.int(id)])
96 + }
97 +
98 + func getById(_ id: Int64) async throws -> CrmContactEntity? {
99 + try await db.query(
100 + "SELECT \(Self.columns) FROM crm_contacts WHERE id = ?",
101 + [.int(id)],
102 + map: Self.map
103 + ).first
104 + }
105 +
106 + func getByServerId(_ serverId: String) async throws -> CrmContactEntity? {
107 + try await db.query(
108 + "SELECT \(Self.columns) FROM crm_contacts WHERE serverId = ?",
109 + [.text(serverId)],
110 + map: Self.map
111 + ).first
112 + }
113 +
114 + func deleteByServerId(_ serverId: String) async throws {
115 + try await db.write("DELETE FROM crm_contacts WHERE serverId = ?", [.text(serverId)])
116 + }
117 +
118 + func fetchAll() async throws -> [CrmContactEntity] {
119 + try await db.query(
120 + "SELECT \(Self.columns) FROM crm_contacts ORDER BY createdAt DESC",
121 + map: Self.map
122 + )
123 + }
124 +
125 + func getAll() async throws -> [CrmContactEntity] {
126 + try await fetchAll()
127 + }
128 +
129 + func searchIds(_ query: String) async throws -> [Int64] {
130 + try await db.query(
131 + "SELECT rowid FROM crm_contacts_fts WHERE crm_contacts_fts MATCH ?",
132 + [.text(query)]
133 + ) { $0.int64(0) }
134 + }
135 +
136 + @discardableResult
137 + func insertExclusion(_ ex: DuplicateExclusionEntity) async throws -> Int64 {
138 + try await db.insertIgnoring(
139 + "INSERT OR IGNORE INTO duplicate_exclusions (id, idA, idB) VALUES (?, ?, ?)",
140 + [.rowId(ex.id), .int(ex.idA), .int(ex.idB)]
141 + )
142 + }
143 +
144 + func getExclusions() async throws -> [DuplicateExclusionEntity] {
145 + try await db.query("SELECT id, idA, idB FROM duplicate_exclusions") { row in
146 + var e = DuplicateExclusionEntity(idA: row.int64(1), idB: row.int64(2))
147 + e.id = row.int64(0)
148 + return e
149 + }
150 + }
151 +
152 + func deleteExclusionsFor(_ id: Int64) async throws {
153 + try await db.write(
154 + "DELETE FROM duplicate_exclusions WHERE idA = ? OR idB = ?",
155 + [.int(id), .int(id)]
156 + )
157 + }
158 +}
A ios/Card2vcf/Data/Sqlite/SqliteDatabase.swift
+365 -0
@@ -0,0 +1,365 @@
1 +import Foundation
2 +import SQLite3
3 +
4 +extension Notification.Name {
5 + /// Posted (on the main queue) after every successful database write.
6 + static let card2vcfDatabaseDidChange = Notification.Name("fr.ebii.card2vcf.databaseDidChange")
7 +}
8 +
9 +struct DatabaseError: Error, CustomStringConvertible {
10 + let message: String
11 +
12 + var description: String { "DatabaseError: \(message)" }
13 +
14 + static func last(_ db: OpaquePointer, while context: String) -> DatabaseError {
15 + DatabaseError(message: "\(context): \(String(cString: sqlite3_errmsg(db)))")
16 + }
17 +}
18 +
19 +/// Owner of the sqlite3 connection. Mirrors the Room schema of the Android
20 +/// `CrmDatabase` (version 3) with destructive migration semantics.
21 +actor SqliteDatabase {
22 + static let schemaVersion: Int32 = 3
23 + static let fileName = "card2vcf.sqlite"
24 +
25 + private let inMemory: Bool
26 + private var handle: OpaquePointer?
27 +
28 + init(inMemory: Bool = false) {
29 + self.inMemory = inMemory
30 + }
31 +
32 + deinit {
33 + if let handle { sqlite3_close_v2(handle) }
34 + }
35 +
36 + // MARK: - Statement API
37 +
38 + func query<T>(
39 + _ sql: String,
40 + _ bindings: [SqliteValue] = [],
41 + map: (SqliteRow) throws -> T
42 + ) throws -> [T] {
43 + let db = try database()
44 + let stmt = try SqliteStatement(db: db, sql: sql)
45 + try stmt.bind(bindings)
46 + var rows: [T] = []
47 + while try stmt.step() {
48 + rows.append(try map(stmt.row))
49 + }
50 + return rows
51 + }
52 +
53 + /// Runs a write statement (UPDATE/DELETE/INSERT without id) and notifies.
54 + func write(_ sql: String, _ bindings: [SqliteValue] = []) throws {
55 + try run(sql, bindings)
56 + notifyChange()
57 + }
58 +
59 + /// Runs an INSERT and returns `sqlite3_last_insert_rowid`.
60 + func insert(_ sql: String, _ bindings: [SqliteValue] = []) throws -> Int64 {
61 + let db = try database()
62 + try run(sql, bindings)
63 + let id = sqlite3_last_insert_rowid(db)
64 + notifyChange()
65 + return id
66 + }
67 +
68 + /// Runs an INSERT OR IGNORE; returns the new row id, or -1 when the
69 + /// insert was ignored (Room `OnConflictStrategy.IGNORE` semantics).
70 + func insertIgnoring(_ sql: String, _ bindings: [SqliteValue] = []) throws -> Int64 {
71 + let db = try database()
72 + try run(sql, bindings)
73 + let id: Int64 = sqlite3_changes(db) > 0 ? sqlite3_last_insert_rowid(db) : -1
74 + notifyChange()
75 + return id
76 + }
77 +
78 + private func run(_ sql: String, _ bindings: [SqliteValue]) throws {
79 + let db = try database()
80 + let stmt = try SqliteStatement(db: db, sql: sql)
81 + try stmt.bind(bindings)
82 + while try stmt.step() {}
83 + }
84 +
85 + private nonisolated func notifyChange() {
86 + DispatchQueue.main.async {
87 + NotificationCenter.default.post(name: .card2vcfDatabaseDidChange, object: nil)
88 + }
89 + }
90 +
91 + // MARK: - Connection
92 +
93 + private func database() throws -> OpaquePointer {
94 + if let handle { return handle }
95 + let path: String
96 + if inMemory {
97 + path = ":memory:"
98 + } else {
99 + path = try Self.defaultPath()
100 + }
101 + var opened: OpaquePointer?
102 + guard sqlite3_open(path, &opened) == SQLITE_OK, let db = opened else {
103 + let message = opened.map { String(cString: sqlite3_errmsg($0)) } ?? "cannot open \(path)"
104 + sqlite3_close_v2(opened)
105 + throw DatabaseError(message: message)
106 + }
107 + do {
108 + try Self.exec(db, "PRAGMA journal_mode=WAL;")
109 + try Self.exec(db, "PRAGMA foreign_keys=ON;")
110 + // REPLACE conflict resolution must fire the FTS delete trigger.
111 + try Self.exec(db, "PRAGMA recursive_triggers=ON;")
112 + try Self.migrateIfNeeded(db)
113 + } catch {
114 + sqlite3_close_v2(db)
115 + throw error
116 + }
117 + handle = db
118 + return db
119 + }
120 +
121 + private static func defaultPath() throws -> String {
122 + let dir = try FileManager.default.url(
123 + for: .applicationSupportDirectory,
124 + in: .userDomainMask,
125 + appropriateFor: nil,
126 + create: true
127 + )
128 + return dir.appendingPathComponent(fileName).path
129 + }
130 +
131 + private static func exec(_ db: OpaquePointer, _ sql: String) throws {
132 + var errorMessage: UnsafeMutablePointer<CChar>?
133 + guard sqlite3_exec(db, sql, nil, nil, &errorMessage) == SQLITE_OK else {
134 + let message = errorMessage.map { String(cString: $0) } ?? "sqlite3_exec failed"
135 + sqlite3_free(errorMessage)
136 + throw DatabaseError(message: message)
137 + }
138 + }
139 +
140 + /// Android uses `fallbackToDestructiveMigration()`: any stored version other
141 + /// than the current one drops everything and recreates the schema.
142 + private static func migrateIfNeeded(_ db: OpaquePointer) throws {
143 + let stmt = try SqliteStatement(db: db, sql: "PRAGMA user_version")
144 + var version: Int32 = 0
145 + if try stmt.step() {
146 + version = Int32(stmt.row.int64(0))
147 + }
148 + guard version != schemaVersion else { return }
149 + try exec(db, dropAllSql)
150 + try exec(db, schemaSql)
151 + try exec(db, "PRAGMA user_version = \(schemaVersion);")
152 + }
153 +
154 + // MARK: - Schema (Room v3 mirror)
155 +
156 + private static let dropAllSql = """
157 + DROP TRIGGER IF EXISTS trg_crm_contacts_ai;
158 + DROP TRIGGER IF EXISTS trg_crm_contacts_ad;
159 + DROP TRIGGER IF EXISTS trg_crm_contacts_au;
160 + DROP TABLE IF EXISTS crm_contacts_fts;
161 + DROP TABLE IF EXISTS crm_contacts;
162 + DROP TABLE IF EXISTS duplicate_exclusions;
163 + DROP TABLE IF EXISTS entreprises;
164 + DROP TABLE IF EXISTS projets;
165 + DROP TABLE IF EXISTS taches;
166 + DROP TABLE IF EXISTS interactions;
167 + DROP TABLE IF EXISTS workflows;
168 + DROP TABLE IF EXISTS sync_ops;
169 + DROP TABLE IF EXISTS sync_meta;
170 + DROP TABLE IF EXISTS rdv;
171 + DROP TABLE IF EXISTS reservations;
172 + DROP TABLE IF EXISTS indisponibilites;
173 + """
174 +
175 + private static let schemaSql = """
176 + CREATE TABLE crm_contacts (
177 + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
178 + fullName TEXT,
179 + firstName TEXT,
180 + lastName TEXT,
181 + company TEXT,
182 + jobTitle TEXT,
183 + phones TEXT NOT NULL,
184 + emails TEXT NOT NULL,
185 + website TEXT,
186 + address TEXT,
187 + notes TEXT,
188 + cardImagePath TEXT,
189 + profileImagePath TEXT,
190 + createdAt INTEGER NOT NULL,
191 + updatedAt INTEGER NOT NULL,
192 + phonesText TEXT NOT NULL,
193 + emailsText TEXT NOT NULL,
194 + serverId TEXT,
195 + statut TEXT,
196 + etape TEXT,
197 + tags TEXT NOT NULL,
198 + entrepriseServerId TEXT
199 + );
200 +
201 + CREATE VIRTUAL TABLE crm_contacts_fts USING fts5(
202 + fullName, firstName, lastName, company, jobTitle,
203 + website, address, notes, phonesText, emailsText,
204 + content='crm_contacts', content_rowid='id'
205 + );
206 +
207 + CREATE TRIGGER trg_crm_contacts_ai AFTER INSERT ON crm_contacts BEGIN
208 + INSERT INTO crm_contacts_fts(
209 + rowid, fullName, firstName, lastName, company, jobTitle,
210 + website, address, notes, phonesText, emailsText)
211 + VALUES (
212 + new.id, new.fullName, new.firstName, new.lastName, new.company, new.jobTitle,
213 + new.website, new.address, new.notes, new.phonesText, new.emailsText);
214 + END;
215 +
216 + CREATE TRIGGER trg_crm_contacts_ad AFTER DELETE ON crm_contacts BEGIN
217 + INSERT INTO crm_contacts_fts(
218 + crm_contacts_fts, rowid, fullName, firstName, lastName, company, jobTitle,
219 + website, address, notes, phonesText, emailsText)
220 + VALUES (
221 + 'delete', old.id, old.fullName, old.firstName, old.lastName, old.company, old.jobTitle,
222 + old.website, old.address, old.notes, old.phonesText, old.emailsText);
223 + END;
224 +
225 + CREATE TRIGGER trg_crm_contacts_au AFTER UPDATE ON crm_contacts BEGIN
226 + INSERT INTO crm_contacts_fts(
227 + crm_contacts_fts, rowid, fullName, firstName, lastName, company, jobTitle,
228 + website, address, notes, phonesText, emailsText)
229 + VALUES (
230 + 'delete', old.id, old.fullName, old.firstName, old.lastName, old.company, old.jobTitle,
231 + old.website, old.address, old.notes, old.phonesText, old.emailsText);
232 + INSERT INTO crm_contacts_fts(
233 + rowid, fullName, firstName, lastName, company, jobTitle,
234 + website, address, notes, phonesText, emailsText)
235 + VALUES (
236 + new.id, new.fullName, new.firstName, new.lastName, new.company, new.jobTitle,
237 + new.website, new.address, new.notes, new.phonesText, new.emailsText);
238 + END;
239 +
240 + CREATE TABLE duplicate_exclusions (
241 + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
242 + idA INTEGER NOT NULL,
243 + idB INTEGER NOT NULL
244 + );
245 + CREATE UNIQUE INDEX index_duplicate_exclusions_idA_idB
246 + ON duplicate_exclusions(idA, idB);
247 +
248 + CREATE TABLE entreprises (
249 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
250 + serverId TEXT,
251 + nom TEXT NOT NULL,
252 + secteur TEXT NOT NULL,
253 + creePar TEXT NOT NULL,
254 + createdAt INTEGER NOT NULL,
255 + updatedAt INTEGER NOT NULL
256 + );
257 +
258 + CREATE TABLE projets (
259 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
260 + serverId TEXT,
261 + nom TEXT NOT NULL,
262 + description TEXT NOT NULL,
263 + workflowServerId TEXT NOT NULL,
264 + membresJson TEXT NOT NULL,
265 + creePar TEXT NOT NULL,
266 + createdAt INTEGER NOT NULL,
267 + updatedAt INTEGER NOT NULL
268 + );
269 +
270 + CREATE TABLE taches (
271 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
272 + serverId TEXT,
273 + projetLocalId INTEGER,
274 + projetServerId TEXT NOT NULL,
275 + titre TEXT NOT NULL,
276 + description TEXT NOT NULL,
277 + colonneId TEXT NOT NULL,
278 + ordre INTEGER NOT NULL,
279 + assigneA TEXT,
280 + auteur TEXT NOT NULL,
281 + createdAt INTEGER NOT NULL,
282 + updatedAt INTEGER NOT NULL
283 + );
284 +
285 + CREATE TABLE interactions (
286 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
287 + serverId TEXT,
288 + contactServerId TEXT NOT NULL,
289 + type TEXT NOT NULL,
290 + sujet TEXT NOT NULL,
291 + description TEXT NOT NULL,
292 + creePar TEXT NOT NULL,
293 + createdAt INTEGER NOT NULL,
294 + updatedAt INTEGER
295 + );
296 +
297 + CREATE TABLE workflows (
298 + serverId TEXT PRIMARY KEY NOT NULL,
299 + nom TEXT NOT NULL,
300 + description TEXT NOT NULL,
301 + colonnesJson TEXT NOT NULL,
302 + creePar TEXT NOT NULL,
303 + createdAt INTEGER NOT NULL
304 + );
305 +
306 + CREATE TABLE sync_ops (
307 + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
308 + entityType TEXT NOT NULL,
309 + op TEXT NOT NULL,
310 + payloadJson TEXT NOT NULL,
311 + localId INTEGER,
312 + serverId TEXT,
313 + createdAt INTEGER NOT NULL,
314 + attempts INTEGER NOT NULL
315 + );
316 +
317 + CREATE TABLE sync_meta (
318 + "key" TEXT PRIMARY KEY NOT NULL,
319 + "value" TEXT NOT NULL
320 + );
321 +
322 + CREATE TABLE rdv (
323 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
324 + serverId TEXT,
325 + titre TEXT NOT NULL,
326 + description TEXT NOT NULL,
327 + lieu TEXT NOT NULL,
328 + debutMs INTEGER NOT NULL,
329 + finMs INTEGER NOT NULL,
330 + contactIdsJson TEXT NOT NULL,
331 + projetId TEXT,
332 + updatedAt INTEGER NOT NULL,
333 + calendarEventId INTEGER,
334 + dirtyLocal INTEGER NOT NULL
335 + );
336 +
337 + CREATE TABLE reservations (
338 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
339 + serverId TEXT,
340 + cibleType TEXT NOT NULL,
341 + cibleId TEXT NOT NULL,
342 + debutMs INTEGER NOT NULL,
343 + finMs INTEGER NOT NULL,
344 + motif TEXT NOT NULL,
345 + statut TEXT NOT NULL,
346 + updatedAt INTEGER NOT NULL,
347 + calendarEventId INTEGER,
348 + dirtyLocal INTEGER NOT NULL,
349 + conflictPending INTEGER NOT NULL
350 + );
351 +
352 + CREATE TABLE indisponibilites (
353 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
354 + serverId TEXT,
355 + cibleType TEXT NOT NULL,
356 + cibleId TEXT NOT NULL,
357 + debutMs INTEGER NOT NULL,
358 + finMs INTEGER NOT NULL,
359 + nature TEXT,
360 + commentaire TEXT,
361 + updatedAt INTEGER NOT NULL,
362 + calendarEventId INTEGER
363 + );
364 + """
365 +}
A ios/Card2vcf/Data/Sqlite/SqliteEntrepriseDao.swift
+59 -0
@@ -0,0 +1,59 @@
1 +import Foundation
2 +
3 +struct SqliteEntrepriseDao: EntrepriseDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = "localId, serverId, nom, secteur, creePar, createdAt, updatedAt"
7 +
8 + private static func map(_ row: SqliteRow) -> EntrepriseEntity {
9 + var e = EntrepriseEntity()
10 + e.localId = row.int64(0)
11 + e.serverId = row.textOrNil(1)
12 + e.nom = row.text(2)
13 + e.secteur = row.text(3)
14 + e.creePar = row.text(4)
15 + e.createdAt = row.int64(5)
16 + e.updatedAt = row.int64(6)
17 + return e
18 + }
19 +
20 + @discardableResult
21 + func upsert(_ entity: EntrepriseEntity) async throws -> Int64 {
22 + try await db.insert(
23 + "INSERT OR REPLACE INTO entreprises (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?)",
24 + [
25 + .rowId(entity.localId),
26 + .optText(entity.serverId),
27 + .text(entity.nom),
28 + .text(entity.secteur),
29 + .text(entity.creePar),
30 + .int(entity.createdAt),
31 + .int(entity.updatedAt),
32 + ]
33 + )
34 + }
35 +
36 + func getByServerId(_ serverId: String) async throws -> EntrepriseEntity? {
37 + try await db.query(
38 + "SELECT \(Self.columns) FROM entreprises WHERE serverId = ?",
39 + [.text(serverId)],
40 + map: Self.map
41 + ).first
42 + }
43 +
44 + func getByLocalId(_ localId: Int64) async throws -> EntrepriseEntity? {
45 + try await db.query(
46 + "SELECT \(Self.columns) FROM entreprises WHERE localId = ?",
47 + [.int(localId)],
48 + map: Self.map
49 + ).first
50 + }
51 +
52 + func deleteByServerId(_ serverId: String) async throws {
53 + try await db.write("DELETE FROM entreprises WHERE serverId = ?", [.text(serverId)])
54 + }
55 +
56 + func listAll() async throws -> [EntrepriseEntity] {
57 + try await db.query("SELECT \(Self.columns) FROM entreprises", map: Self.map)
58 + }
59 +}
A ios/Card2vcf/Data/Sqlite/SqliteIndisponibiliteDao.swift
+68 -0
@@ -0,0 +1,68 @@
1 +import Foundation
2 +
3 +struct SqliteIndisponibiliteDao: IndisponibiliteDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, cibleType, cibleId, debutMs, finMs, nature, commentaire, \
8 + updatedAt, calendarEventId
9 + """
10 +
11 + private static func map(_ row: SqliteRow) -> IndisponibiliteEntity {
12 + var e = IndisponibiliteEntity()
13 + e.localId = row.int64(0)
14 + e.serverId = row.textOrNil(1)
15 + e.cibleType = row.text(2)
16 + e.cibleId = row.text(3)
17 + e.debutMs = row.int64(4)
18 + e.finMs = row.int64(5)
19 + e.nature = row.textOrNil(6)
20 + e.commentaire = row.textOrNil(7)
21 + e.updatedAt = row.int64(8)
22 + e.calendarEventId = row.int64OrNil(9)
23 + return e
24 + }
25 +
26 + @discardableResult
27 + func upsert(_ entity: IndisponibiliteEntity) async throws -> Int64 {
28 + try await db.insert(
29 + "INSERT OR REPLACE INTO indisponibilites (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
30 + [
31 + .rowId(entity.localId),
32 + .optText(entity.serverId),
33 + .text(entity.cibleType),
34 + .text(entity.cibleId),
35 + .int(entity.debutMs),
36 + .int(entity.finMs),
37 + .optText(entity.nature),
38 + .optText(entity.commentaire),
39 + .int(entity.updatedAt),
40 + .optInt(entity.calendarEventId),
41 + ]
42 + )
43 + }
44 +
45 + func getByServerId(_ serverId: String) async throws -> IndisponibiliteEntity? {
46 + try await db.query(
47 + "SELECT \(Self.columns) FROM indisponibilites WHERE serverId = ?",
48 + [.text(serverId)],
49 + map: Self.map
50 + ).first
51 + }
52 +
53 + func deleteByServerId(_ serverId: String) async throws {
54 + try await db.write("DELETE FROM indisponibilites WHERE serverId = ?", [.text(serverId)])
55 + }
56 +
57 + func listByCible(cibleType: String, cibleId: String) async throws -> [IndisponibiliteEntity] {
58 + try await db.query(
59 + "SELECT \(Self.columns) FROM indisponibilites WHERE cibleType = ? AND cibleId = ?",
60 + [.text(cibleType), .text(cibleId)],
61 + map: Self.map
62 + )
63 + }
64 +
65 + func listAll() async throws -> [IndisponibiliteEntity] {
66 + try await db.query("SELECT \(Self.columns) FROM indisponibilites", map: Self.map)
67 + }
68 +}
A ios/Card2vcf/Data/Sqlite/SqliteInteractionDao.swift
+73 -0
@@ -0,0 +1,73 @@
1 +import Foundation
2 +
3 +struct SqliteInteractionDao: InteractionDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, contactServerId, type, sujet, description, creePar, createdAt, updatedAt
8 + """
9 +
10 + private static func map(_ row: SqliteRow) -> InteractionEntity {
11 + var e = InteractionEntity()
12 + e.localId = row.int64(0)
13 + e.serverId = row.textOrNil(1)
14 + e.contactServerId = row.text(2)
15 + e.type = row.text(3)
16 + e.sujet = row.text(4)
17 + e.description = row.text(5)
18 + e.creePar = row.text(6)
19 + e.createdAt = row.int64(7)
20 + e.updatedAt = row.int64OrNil(8)
21 + return e
22 + }
23 +
24 + @discardableResult
25 + func upsert(_ entity: InteractionEntity) async throws -> Int64 {
26 + try await db.insert(
27 + "INSERT OR REPLACE INTO interactions (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
28 + [
29 + .rowId(entity.localId),
30 + .optText(entity.serverId),
31 + .text(entity.contactServerId),
32 + .text(entity.type),
33 + .text(entity.sujet),
34 + .text(entity.description),
35 + .text(entity.creePar),
36 + .int(entity.createdAt),
37 + .optInt(entity.updatedAt),
38 + ]
39 + )
40 + }
41 +
42 + func getByServerId(_ serverId: String) async throws -> InteractionEntity? {
43 + try await db.query(
44 + "SELECT \(Self.columns) FROM interactions WHERE serverId = ?",
45 + [.text(serverId)],
46 + map: Self.map
47 + ).first
48 + }
49 +
50 + func deleteByServerId(_ serverId: String) async throws {
51 + try await db.write("DELETE FROM interactions WHERE serverId = ?", [.text(serverId)])
52 + }
53 +
54 + func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
55 + try await db.query(
56 + "SELECT \(Self.columns) FROM interactions WHERE contactServerId = ?",
57 + [.text(contactServerId)],
58 + map: Self.map
59 + )
60 + }
61 +
62 + func fetchByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
63 + try await db.query(
64 + "SELECT \(Self.columns) FROM interactions WHERE contactServerId = ? ORDER BY createdAt DESC",
65 + [.text(contactServerId)],
66 + map: Self.map
67 + )
68 + }
69 +
70 + func listAll() async throws -> [InteractionEntity] {
71 + try await db.query("SELECT \(Self.columns) FROM interactions", map: Self.map)
72 + }
73 +}
A ios/Card2vcf/Data/Sqlite/SqliteProjetDao.swift
+69 -0
@@ -0,0 +1,69 @@
1 +import Foundation
2 +
3 +struct SqliteProjetDao: ProjetDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, nom, description, workflowServerId, membresJson, creePar, createdAt, updatedAt
8 + """
9 +
10 + private static func map(_ row: SqliteRow) -> ProjetEntity {
11 + var e = ProjetEntity()
12 + e.localId = row.int64(0)
13 + e.serverId = row.textOrNil(1)
14 + e.nom = row.text(2)
15 + e.description = row.text(3)
16 + e.workflowServerId = row.text(4)
17 + e.membresJson = row.text(5)
18 + e.creePar = row.text(6)
19 + e.createdAt = row.int64(7)
20 + e.updatedAt = row.int64(8)
21 + return e
22 + }
23 +
24 + @discardableResult
25 + func upsert(_ entity: ProjetEntity) async throws -> Int64 {
26 + try await db.insert(
27 + "INSERT OR REPLACE INTO projets (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
28 + [
29 + .rowId(entity.localId),
30 + .optText(entity.serverId),
31 + .text(entity.nom),
32 + .text(entity.description),
33 + .text(entity.workflowServerId),
34 + .text(entity.membresJson),
35 + .text(entity.creePar),
36 + .int(entity.createdAt),
37 + .int(entity.updatedAt),
38 + ]
39 + )
40 + }
41 +
42 + func getByServerId(_ serverId: String) async throws -> ProjetEntity? {
43 + try await db.query(
44 + "SELECT \(Self.columns) FROM projets WHERE serverId = ?",
45 + [.text(serverId)],
46 + map: Self.map
47 + ).first
48 + }
49 +
50 + func getByLocalId(_ localId: Int64) async throws -> ProjetEntity? {
51 + try await db.query(
52 + "SELECT \(Self.columns) FROM projets WHERE localId = ?",
53 + [.int(localId)],
54 + map: Self.map
55 + ).first
56 + }
57 +
58 + func deleteByServerId(_ serverId: String) async throws {
59 + try await db.write("DELETE FROM projets WHERE serverId = ?", [.text(serverId)])
60 + }
61 +
62 + func listAll() async throws -> [ProjetEntity] {
63 + try await db.query("SELECT \(Self.columns) FROM projets", map: Self.map)
64 + }
65 +
66 + func fetchAll() async throws -> [ProjetEntity] {
67 + try await db.query("SELECT \(Self.columns) FROM projets ORDER BY nom ASC", map: Self.map)
68 + }
69 +}
A ios/Card2vcf/Data/Sqlite/SqliteRdvDao.swift
+88 -0
@@ -0,0 +1,88 @@
1 +import Foundation
2 +
3 +struct SqliteRdvDao: RdvDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, titre, description, lieu, debutMs, finMs, contactIdsJson, \
8 + projetId, updatedAt, calendarEventId, dirtyLocal
9 + """
10 +
11 + private static func map(_ row: SqliteRow) -> RdvEntity {
12 + var e = RdvEntity()
13 + e.localId = row.int64(0)
14 + e.serverId = row.textOrNil(1)
15 + e.titre = row.text(2)
16 + e.description = row.text(3)
17 + e.lieu = row.text(4)
18 + e.debutMs = row.int64(5)
19 + e.finMs = row.int64(6)
20 + e.contactIdsJson = row.text(7)
21 + e.projetId = row.textOrNil(8)
22 + e.updatedAt = row.int64(9)
23 + e.calendarEventId = row.int64OrNil(10)
24 + e.dirtyLocal = row.bool(11)
25 + return e
26 + }
27 +
28 + @discardableResult
29 + func upsert(_ entity: RdvEntity) async throws -> Int64 {
30 + try await db.insert(
31 + "INSERT OR REPLACE INTO rdv (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
32 + [
33 + .rowId(entity.localId),
34 + .optText(entity.serverId),
35 + .text(entity.titre),
36 + .text(entity.description),
37 + .text(entity.lieu),
38 + .int(entity.debutMs),
39 + .int(entity.finMs),
40 + .text(entity.contactIdsJson),
41 + .optText(entity.projetId),
42 + .int(entity.updatedAt),
43 + .optInt(entity.calendarEventId),
44 + .bool(entity.dirtyLocal),
45 + ]
46 + )
47 + }
48 +
49 + func getByServerId(_ serverId: String) async throws -> RdvEntity? {
50 + try await db.query(
51 + "SELECT \(Self.columns) FROM rdv WHERE serverId = ?",
52 + [.text(serverId)],
53 + map: Self.map
54 + ).first
55 + }
56 +
57 + func getByLocalId(_ localId: Int64) async throws -> RdvEntity? {
58 + try await db.query(
59 + "SELECT \(Self.columns) FROM rdv WHERE localId = ?",
60 + [.int(localId)],
61 + map: Self.map
62 + ).first
63 + }
64 +
65 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> RdvEntity? {
66 + try await db.query(
67 + "SELECT \(Self.columns) FROM rdv WHERE calendarEventId = ?",
68 + [.int(calendarEventId)],
69 + map: Self.map
70 + ).first
71 + }
72 +
73 + func listDirty() async throws -> [RdvEntity] {
74 + try await db.query("SELECT \(Self.columns) FROM rdv WHERE dirtyLocal = 1", map: Self.map)
75 + }
76 +
77 + func deleteByServerId(_ serverId: String) async throws {
78 + try await db.write("DELETE FROM rdv WHERE serverId = ?", [.text(serverId)])
79 + }
80 +
81 + func deleteByLocalId(_ localId: Int64) async throws {
82 + try await db.write("DELETE FROM rdv WHERE localId = ?", [.int(localId)])
83 + }
84 +
85 + func listAll() async throws -> [RdvEntity] {
86 + try await db.query("SELECT \(Self.columns) FROM rdv", map: Self.map)
87 + }
88 +}
A ios/Card2vcf/Data/Sqlite/SqliteReservationDao.swift
+99 -0
@@ -0,0 +1,99 @@
1 +import Foundation
2 +
3 +struct SqliteReservationDao: ReservationDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, cibleType, cibleId, debutMs, finMs, motif, statut, \
8 + updatedAt, calendarEventId, dirtyLocal, conflictPending
9 + """
10 +
11 + private static func map(_ row: SqliteRow) -> ReservationEntity {
12 + var e = ReservationEntity()
13 + e.localId = row.int64(0)
14 + e.serverId = row.textOrNil(1)
15 + e.cibleType = row.text(2)
16 + e.cibleId = row.text(3)
17 + e.debutMs = row.int64(4)
18 + e.finMs = row.int64(5)
19 + e.motif = row.text(6)
20 + e.statut = row.text(7)
21 + e.updatedAt = row.int64(8)
22 + e.calendarEventId = row.int64OrNil(9)
23 + e.dirtyLocal = row.bool(10)
24 + e.conflictPending = row.bool(11)
25 + return e
26 + }
27 +
28 + @discardableResult
29 + func upsert(_ entity: ReservationEntity) async throws -> Int64 {
30 + try await db.insert(
31 + "INSERT OR REPLACE INTO reservations (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
32 + [
33 + .rowId(entity.localId),
34 + .optText(entity.serverId),
35 + .text(entity.cibleType),
36 + .text(entity.cibleId),
37 + .int(entity.debutMs),
38 + .int(entity.finMs),
39 + .text(entity.motif),
40 + .text(entity.statut),
41 + .int(entity.updatedAt),
42 + .optInt(entity.calendarEventId),
43 + .bool(entity.dirtyLocal),
44 + .bool(entity.conflictPending),
45 + ]
46 + )
47 + }
48 +
49 + func getByServerId(_ serverId: String) async throws -> ReservationEntity? {
50 + try await db.query(
51 + "SELECT \(Self.columns) FROM reservations WHERE serverId = ?",
52 + [.text(serverId)],
53 + map: Self.map
54 + ).first
55 + }
56 +
57 + func getByLocalId(_ localId: Int64) async throws -> ReservationEntity? {
58 + try await db.query(
59 + "SELECT \(Self.columns) FROM reservations WHERE localId = ?",
60 + [.int(localId)],
61 + map: Self.map
62 + ).first
63 + }
64 +
65 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> ReservationEntity? {
66 + try await db.query(
67 + "SELECT \(Self.columns) FROM reservations WHERE calendarEventId = ?",
68 + [.int(calendarEventId)],
69 + map: Self.map
70 + ).first
71 + }
72 +
73 + func listDirty() async throws -> [ReservationEntity] {
74 + try await db.query(
75 + "SELECT \(Self.columns) FROM reservations WHERE dirtyLocal = 1",
76 + map: Self.map
77 + )
78 + }
79 +
80 + func deleteByServerId(_ serverId: String) async throws {
81 + try await db.write("DELETE FROM reservations WHERE serverId = ?", [.text(serverId)])
82 + }
83 +
84 + func deleteByLocalId(_ localId: Int64) async throws {
85 + try await db.write("DELETE FROM reservations WHERE localId = ?", [.int(localId)])
86 + }
87 +
88 + func listByCible(cibleType: String, cibleId: String) async throws -> [ReservationEntity] {
89 + try await db.query(
90 + "SELECT \(Self.columns) FROM reservations WHERE cibleType = ? AND cibleId = ?",
91 + [.text(cibleType), .text(cibleId)],
92 + map: Self.map
93 + )
94 + }
95 +
96 + func listAll() async throws -> [ReservationEntity] {
97 + try await db.query("SELECT \(Self.columns) FROM reservations", map: Self.map)
98 + }
99 +}
A ios/Card2vcf/Data/Sqlite/SqliteStatement.swift
+109 -0
@@ -0,0 +1,109 @@
1 +import Foundation
2 +import SQLite3
3 +
4 +private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
5 +
6 +/// A single value bound to a `?` placeholder.
7 +enum SqliteValue {
8 + case null
9 + case int(Int64)
10 + case real(Double)
11 + case text(String)
12 + case blob(Data)
13 +
14 + static func optText(_ value: String?) -> SqliteValue {
15 + value.map { .text($0) } ?? .null
16 + }
17 +
18 + static func optInt(_ value: Int64?) -> SqliteValue {
19 + value.map { .int($0) } ?? .null
20 + }
21 +
22 + static func bool(_ value: Bool) -> SqliteValue {
23 + .int(value ? 1 : 0)
24 + }
25 +
26 + /// Room semantics for `@PrimaryKey(autoGenerate = true)`: 0 means "not set",
27 + /// bound as NULL so SQLite assigns the next rowid.
28 + static func rowId(_ id: Int64) -> SqliteValue {
29 + id == 0 ? .null : .int(id)
30 + }
31 +}
32 +
33 +/// Column accessors for the current row of a stepped statement.
34 +struct SqliteRow {
35 + let stmt: OpaquePointer
36 +
37 + func int64(_ index: Int32) -> Int64 { sqlite3_column_int64(stmt, index) }
38 +
39 + func int64OrNil(_ index: Int32) -> Int64? {
40 + isNull(index) ? nil : sqlite3_column_int64(stmt, index)
41 + }
42 +
43 + func int(_ index: Int32) -> Int { Int(sqlite3_column_int64(stmt, index)) }
44 +
45 + func bool(_ index: Int32) -> Bool { sqlite3_column_int64(stmt, index) != 0 }
46 +
47 + func text(_ index: Int32) -> String { textOrNil(index) ?? "" }
48 +
49 + func textOrNil(_ index: Int32) -> String? {
50 + guard let cString = sqlite3_column_text(stmt, index) else { return nil }
51 + return String(cString: cString)
52 + }
53 +
54 + private func isNull(_ index: Int32) -> Bool {
55 + sqlite3_column_type(stmt, index) == SQLITE_NULL
56 + }
57 +}
58 +
59 +/// Prepared-statement wrapper; finalizes on deinit.
60 +final class SqliteStatement {
61 + private let db: OpaquePointer
62 + private let stmt: OpaquePointer
63 +
64 + init(db: OpaquePointer, sql: String) throws {
65 + var stmt: OpaquePointer?
66 + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
67 + throw DatabaseError.last(db, while: "prepare: \(sql)")
68 + }
69 + self.db = db
70 + self.stmt = stmt
71 + }
72 +
73 + deinit { sqlite3_finalize(stmt) }
74 +
75 + func bind(_ values: [SqliteValue]) throws {
76 + for (offset, value) in values.enumerated() {
77 + let index = Int32(offset + 1)
78 + let rc: Int32
79 + switch value {
80 + case .null:
81 + rc = sqlite3_bind_null(stmt, index)
82 + case .int(let n):
83 + rc = sqlite3_bind_int64(stmt, index, n)
84 + case .real(let d):
85 + rc = sqlite3_bind_double(stmt, index, d)
86 + case .text(let s):
87 + rc = sqlite3_bind_text(stmt, index, s, -1, sqliteTransient)
88 + case .blob(let data):
89 + rc = data.withUnsafeBytes { buffer in
90 + sqlite3_bind_blob(stmt, index, buffer.baseAddress, Int32(data.count), sqliteTransient)
91 + }
92 + }
93 + guard rc == SQLITE_OK else {
94 + throw DatabaseError.last(db, while: "bind parameter \(index)")
95 + }
96 + }
97 + }
98 +
99 + /// Steps once; true when a result row is available, false when done.
100 + func step() throws -> Bool {
101 + switch sqlite3_step(stmt) {
102 + case SQLITE_ROW: return true
103 + case SQLITE_DONE: return false
104 + default: throw DatabaseError.last(db, while: "step")
105 + }
106 + }
107 +
108 + var row: SqliteRow { SqliteRow(stmt: stmt) }
109 +}
A ios/Card2vcf/Data/Sqlite/SqliteSyncDaos.swift
+74 -0
@@ -0,0 +1,74 @@
1 +import Foundation
2 +
3 +struct SqliteSyncOpDao: SyncOpDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = "id, entityType, op, payloadJson, localId, serverId, createdAt, attempts"
7 +
8 + private static func map(_ row: SqliteRow) -> SyncOpEntity {
9 + var e = SyncOpEntity(entityType: row.text(1), op: row.text(2))
10 + e.id = row.int64(0)
11 + e.payloadJson = row.text(3)
12 + e.localId = row.int64OrNil(4)
13 + e.serverId = row.textOrNil(5)
14 + e.createdAt = row.int64(6)
15 + e.attempts = row.int(7)
16 + return e
17 + }
18 +
19 + @discardableResult
20 + func insert(_ entity: SyncOpEntity) async throws -> Int64 {
21 + try await db.insert(
22 + "INSERT OR REPLACE INTO sync_ops (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
23 + [
24 + .rowId(entity.id),
25 + .text(entity.entityType),
26 + .text(entity.op),
27 + .text(entity.payloadJson),
28 + .optInt(entity.localId),
29 + .optText(entity.serverId),
30 + .int(entity.createdAt),
31 + .int(Int64(entity.attempts)),
32 + ]
33 + )
34 + }
35 +
36 + func listAll() async throws -> [SyncOpEntity] {
37 + try await db.query(
38 + "SELECT \(Self.columns) FROM sync_ops ORDER BY createdAt ASC",
39 + map: Self.map
40 + )
41 + }
42 +
43 + func getById(_ id: Int64) async throws -> SyncOpEntity? {
44 + try await db.query(
45 + "SELECT \(Self.columns) FROM sync_ops WHERE id = ?",
46 + [.int(id)],
47 + map: Self.map
48 + ).first
49 + }
50 +
51 + func deleteById(_ id: Int64) async throws {
52 + try await db.write("DELETE FROM sync_ops WHERE id = ?", [.int(id)])
53 + }
54 +}
55 +
56 +struct SqliteSyncMetaDao: SyncMetaDao {
57 + let db: SqliteDatabase
58 +
59 + func upsert(_ entity: SyncMetaEntity) async throws {
60 + try await db.write(
61 + "INSERT OR REPLACE INTO sync_meta (\"key\", \"value\") VALUES (?, ?)",
62 + [.text(entity.key), .text(entity.value)]
63 + )
64 + }
65 +
66 + func get(_ key: String) async throws -> SyncMetaEntity? {
67 + try await db.query(
68 + "SELECT \"key\", \"value\" FROM sync_meta WHERE \"key\" = ?",
69 + [.text(key)]
70 + ) { row in
71 + SyncMetaEntity(key: row.text(0), value: row.text(1))
72 + }.first
73 + }
74 +}
A ios/Card2vcf/Data/Sqlite/SqliteTacheDao.swift
+92 -0
@@ -0,0 +1,92 @@
1 +import Foundation
2 +
3 +struct SqliteTacheDao: TacheDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, projetLocalId, projetServerId, titre, description, colonneId, \
8 + ordre, assigneA, auteur, createdAt, updatedAt
9 + """
10 +
11 + private static func map(_ row: SqliteRow) -> TacheEntity {
12 + var e = TacheEntity()
13 + e.localId = row.int64(0)
14 + e.serverId = row.textOrNil(1)
15 + e.projetLocalId = row.int64OrNil(2)
16 + e.projetServerId = row.text(3)
17 + e.titre = row.text(4)
18 + e.description = row.text(5)
19 + e.colonneId = row.text(6)
20 + e.ordre = row.int(7)
21 + e.assigneA = row.textOrNil(8)
22 + e.auteur = row.text(9)
23 + e.createdAt = row.int64(10)
24 + e.updatedAt = row.int64(11)
25 + return e
26 + }
27 +
28 + @discardableResult
29 + func upsert(_ entity: TacheEntity) async throws -> Int64 {
30 + try await db.insert(
31 + "INSERT OR REPLACE INTO taches (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
32 + [
33 + .rowId(entity.localId),
34 + .optText(entity.serverId),
35 + .optInt(entity.projetLocalId),
36 + .text(entity.projetServerId),
37 + .text(entity.titre),
38 + .text(entity.description),
39 + .text(entity.colonneId),
40 + .int(Int64(entity.ordre)),
41 + .optText(entity.assigneA),
42 + .text(entity.auteur),
43 + .int(entity.createdAt),
44 + .int(entity.updatedAt),
45 + ]
46 + )
47 + }
48 +
49 + func getByServerId(_ serverId: String) async throws -> TacheEntity? {
50 + try await db.query(
51 + "SELECT \(Self.columns) FROM taches WHERE serverId = ?",
52 + [.text(serverId)],
53 + map: Self.map
54 + ).first
55 + }
56 +
57 + func getByLocalId(_ localId: Int64) async throws -> TacheEntity? {
58 + try await db.query(
59 + "SELECT \(Self.columns) FROM taches WHERE localId = ?",
60 + [.int(localId)],
61 + map: Self.map
62 + ).first
63 + }
64 +
65 + func deleteByServerId(_ serverId: String) async throws {
66 + try await db.write("DELETE FROM taches WHERE serverId = ?", [.text(serverId)])
67 + }
68 +
69 + func deleteByLocalId(_ localId: Int64) async throws {
70 + try await db.write("DELETE FROM taches WHERE localId = ?", [.int(localId)])
71 + }
72 +
73 + func listByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity] {
74 + try await db.query(
75 + "SELECT \(Self.columns) FROM taches WHERE projetServerId = ?",
76 + [.text(projetServerId)],
77 + map: Self.map
78 + )
79 + }
80 +
81 + func fetchByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity] {
82 + try await db.query(
83 + "SELECT \(Self.columns) FROM taches WHERE projetServerId = ? ORDER BY ordre ASC",
84 + [.text(projetServerId)],
85 + map: Self.map
86 + )
87 + }
88 +
89 + func listAll() async throws -> [TacheEntity] {
90 + try await db.query("SELECT \(Self.columns) FROM taches", map: Self.map)
91 + }
92 +}
A ios/Card2vcf/Data/Sqlite/SqliteWorkflowDao.swift
+47 -0
@@ -0,0 +1,47 @@
1 +import Foundation
2 +
3 +struct SqliteWorkflowDao: WorkflowDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = "serverId, nom, description, colonnesJson, creePar, createdAt"
7 +
8 + private static func map(_ row: SqliteRow) -> WorkflowEntity {
9 + var e = WorkflowEntity(serverId: row.text(0))
10 + e.nom = row.text(1)
11 + e.description = row.text(2)
12 + e.colonnesJson = row.text(3)
13 + e.creePar = row.text(4)
14 + e.createdAt = row.int64(5)
15 + return e
16 + }
17 +
18 + func upsert(_ entity: WorkflowEntity) async throws {
19 + try await db.write(
20 + "INSERT OR REPLACE INTO workflows (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?)",
21 + [
22 + .text(entity.serverId),
23 + .text(entity.nom),
24 + .text(entity.description),
25 + .text(entity.colonnesJson),
26 + .text(entity.creePar),
27 + .int(entity.createdAt),
28 + ]
29 + )
30 + }
31 +
32 + func getByServerId(_ serverId: String) async throws -> WorkflowEntity? {
33 + try await db.query(
34 + "SELECT \(Self.columns) FROM workflows WHERE serverId = ?",
35 + [.text(serverId)],
36 + map: Self.map
37 + ).first
38 + }
39 +
40 + func deleteByServerId(_ serverId: String) async throws {
41 + try await db.write("DELETE FROM workflows WHERE serverId = ?", [.text(serverId)])
42 + }
43 +
44 + func listAll() async throws -> [WorkflowEntity] {
45 + try await db.query("SELECT \(Self.columns) FROM workflows", map: Self.map)
46 + }
47 +}
A ios/Card2vcf/Data/SyncMetaDao.swift
+10 -0
@@ -0,0 +1,10 @@
1 +import Foundation
2 +
3 +/// Port of the Room `SyncMetaDao`.
4 +protocol SyncMetaDao {
5 + /// INSERT OR REPLACE INTO sync_meta.
6 + func upsert(_ entity: SyncMetaEntity) async throws
7 +
8 + /// SELECT * FROM sync_meta WHERE key = :key
9 + func get(_ key: String) async throws -> SyncMetaEntity?
10 +}
A ios/Card2vcf/Data/SyncMetaEntity.swift
+7 -0
@@ -0,0 +1,7 @@
1 +import Foundation
2 +
3 +/// Room entity `sync_meta` (key is the primary key).
4 +struct SyncMetaEntity: Equatable {
5 + var key: String
6 + var value: String
7 +}
A ios/Card2vcf/Data/SyncOpDao.swift
+17 -0
@@ -0,0 +1,17 @@
1 +import Foundation
2 +
3 +/// Port of the Room `SyncOpDao`.
4 +protocol SyncOpDao {
5 + /// INSERT OR REPLACE INTO sync_ops; returns the row id.
6 + @discardableResult
7 + func insert(_ entity: SyncOpEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM sync_ops ORDER BY createdAt ASC
10 + func listAll() async throws -> [SyncOpEntity]
11 +
12 + /// SELECT * FROM sync_ops WHERE id = :id
13 + func getById(_ id: Int64) async throws -> SyncOpEntity?
14 +
15 + /// DELETE FROM sync_ops WHERE id = :id
16 + func deleteById(_ id: Int64) async throws
17 +}
A ios/Card2vcf/Data/SyncOpEntity.swift
+13 -0
@@ -0,0 +1,13 @@
1 +import Foundation
2 +
3 +/// Room entity `sync_ops` (id is the auto-generated primary key).
4 +struct SyncOpEntity: Equatable {
5 + var id: Int64 = 0
6 + var entityType: String
7 + var op: String
8 + var payloadJson: String = "{}"
9 + var localId: Int64? = nil
10 + var serverId: String? = nil
11 + var createdAt: Int64 = 0
12 + var attempts: Int = 0
13 +}
A ios/Card2vcf/Data/TacheDao.swift
+30 -0
@@ -0,0 +1,30 @@
1 +import Foundation
2 +
3 +/// Port of the Room `TacheDao`.
4 +protocol TacheDao {
5 + /// INSERT OR REPLACE INTO taches; returns the row id.
6 + @discardableResult
7 + func upsert(_ entity: TacheEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM taches WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> TacheEntity?
11 +
12 + /// SELECT * FROM taches WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> TacheEntity?
14 +
15 + /// DELETE FROM taches WHERE serverId = :serverId
16 + func deleteByServerId(_ serverId: String) async throws
17 +
18 + /// DELETE FROM taches WHERE localId = :localId
19 + func deleteByLocalId(_ localId: Int64) async throws
20 +
21 + /// SELECT * FROM taches WHERE projetServerId = :projetServerId
22 + func listByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity]
23 +
24 + /// Kotlin `observeByProjetServerId(...): Flow<List<TacheEntity>>` — manual refresh model.
25 + /// SELECT * FROM taches WHERE projetServerId = :projetServerId ORDER BY ordre ASC
26 + func fetchByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity]
27 +
28 + /// SELECT * FROM taches
29 + func listAll() async throws -> [TacheEntity]
30 +}
A ios/Card2vcf/Data/TacheEntity.swift
+17 -0
@@ -0,0 +1,17 @@
1 +import Foundation
2 +
3 +/// Room entity `taches`.
4 +struct TacheEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var projetLocalId: Int64? = nil
8 + var projetServerId: String = ""
9 + var titre: String = ""
10 + var description: String = ""
11 + var colonneId: String = ""
12 + var ordre: Int = 0
13 + var assigneA: String? = nil
14 + var auteur: String = ""
15 + var createdAt: Int64 = 0
16 + var updatedAt: Int64 = 0
17 +}
A ios/Card2vcf/Data/WorkflowDao.swift
+16 -0
@@ -0,0 +1,16 @@
1 +import Foundation
2 +
3 +/// Port of the Room `WorkflowDao`.
4 +protocol WorkflowDao {
5 + /// INSERT OR REPLACE INTO workflows.
6 + func upsert(_ entity: WorkflowEntity) async throws
7 +
8 + /// SELECT * FROM workflows WHERE serverId = :serverId
9 + func getByServerId(_ serverId: String) async throws -> WorkflowEntity?
10 +
11 + /// DELETE FROM workflows WHERE serverId = :serverId
12 + func deleteByServerId(_ serverId: String) async throws
13 +
14 + /// SELECT * FROM workflows
15 + func listAll() async throws -> [WorkflowEntity]
16 +}
A ios/Card2vcf/Data/WorkflowEntity.swift
+11 -0
@@ -0,0 +1,11 @@
1 +import Foundation
2 +
3 +/// Room entity `workflows` (serverId is the primary key).
4 +struct WorkflowEntity: Equatable {
5 + var serverId: String
6 + var nom: String = ""
7 + var description: String = ""
8 + var colonnesJson: String = "[]"
9 + var creePar: String = ""
10 + var createdAt: Int64 = 0
11 +}
A ios/Card2vcf/Info.plist
+49 -0
@@ -0,0 +1,49 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>CFBundleDevelopmentRegion</key>
6 + <string>fr</string>
7 + <key>CFBundleDisplayName</key>
8 + <string>Card2vcf</string>
9 + <key>CFBundleExecutable</key>
10 + <string>$(EXECUTABLE_NAME)</string>
11 + <key>CFBundleIdentifier</key>
12 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 + <key>CFBundleInfoDictionaryVersion</key>
14 + <string>6.0</string>
15 + <key>CFBundleName</key>
16 + <string>$(PRODUCT_NAME)</string>
17 + <key>CFBundlePackageType</key>
18 + <string>APPL</string>
19 + <key>CFBundleShortVersionString</key>
20 + <string>1.0.0</string>
21 + <key>CFBundleVersion</key>
22 + <string>1</string>
23 + <key>UILaunchScreen</key>
24 + <dict/>
25 + <key>UISupportedInterfaceOrientations</key>
26 + <array>
27 + <string>UIInterfaceOrientationPortrait</string>
28 + <string>UIInterfaceOrientationLandscapeLeft</string>
29 + <string>UIInterfaceOrientationLandscapeRight</string>
30 + </array>
31 + <key>NSCameraUsageDescription</key>
32 + <string>La caméra sert à scanner les cartes de visite. Le traitement OCR reste 100 % sur l'appareil.</string>
33 + <key>NSContactsUsageDescription</key>
34 + <string>Permet de créer un contact dans le carnet d'adresses du téléphone depuis une carte scannée.</string>
35 + <key>NSCalendarsUsageDescription</key>
36 + <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec Projectiaon).</string>
37 + <key>NSCalendarsFullAccessUsageDescription</key>
38 + <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec Projectiaon).</string>
39 + <key>NSPhotoLibraryAddUsageDescription</key>
40 + <string>Permet d'enregistrer l'image de la carte scannée si vous le demandez.</string>
41 + <key>NSAppTransportSecurity</key>
42 + <dict>
43 + <!-- HTTP clair refusé par défaut dans l'app (ServerUrlPolicy) ; l'exception ATS
44 + n'est effective que si l'utilisateur coche « Autoriser HTTP » (dev/démo). -->
45 + <key>NSAllowsArbitraryLoads</key>
46 + <true/>
47 + </dict>
48 +</dict>
49 +</plist>
A ios/Card2vcf/Kanban/KanbanFilter.swift
+18 -0
@@ -0,0 +1,18 @@
1 +import Foundation
2 +
3 +/// "My tasks" filter (default) vs "All" — unassigned tasks visible only in All.
4 +enum KanbanFilter {
5 + enum Mode {
6 + case mine
7 + case all
8 + }
9 +
10 + static func filter(_ tasks: [TacheEntity], mode: Mode, userName: String) -> [TacheEntity] {
11 + switch mode {
12 + case .all:
13 + return tasks
14 + case .mine:
15 + return tasks.filter { $0.assigneA == userName }
16 + }
17 + }
18 +}
A ios/Card2vcf/OCR/OcrEngine.swift
+20 -0
@@ -0,0 +1,20 @@
1 +import UIKit
2 +
3 +/// Port of android `ocr/OcrEngine.kt`.
4 +/// Input image + engine-configured language(s) -> `OcrResult`.
5 +protocol OcrEngine {
6 + func recognize(_ image: UIImage) async throws -> OcrResult
7 +}
8 +
9 +/// Port of Kotlin `OcrException`.
10 +struct OcrException: Error, LocalizedError {
11 + let message: String
12 + let underlying: Error?
13 +
14 + init(_ message: String, underlying: Error? = nil) {
15 + self.message = message
16 + self.underlying = underlying
17 + }
18 +
19 + var errorDescription: String? { message }
20 +}
A ios/Card2vcf/OCR/OcrOrientation.swift
+43 -0
@@ -0,0 +1,43 @@
1 +import Foundation
2 +
3 +// `OcrException` lives in OcrEngine.swift (Vision engine owner).
4 +
5 +/// Tries several orientations and keeps the one maximizing
6 +/// business-card signals (email, phone, URL, amount of text).
7 +///
8 +/// Image-agnostic port: Android rotated `Bitmap`s via `Matrix`; on iOS the
9 +/// image type and the rotation are injected by the Vision engine owner.
10 +enum OcrOrientation {
11 + static let angles = [0, 90, 180, 270]
12 +
13 + static func recognizeBest<Image>(
14 + image: Image,
15 + rotate: (Image, Int) -> Image,
16 + recognize: (Image) throws -> OcrResult
17 + ) throws -> OcrResult {
18 + var best: OcrResult? = nil
19 + var bestScore = Int.min
20 + for angle in angles {
21 + let candidate = angle == 0 ? image : rotate(image, angle)
22 + guard let result = try? recognize(candidate) else { continue }
23 + let s = score(result)
24 + if s > bestScore {
25 + bestScore = s
26 + best = result
27 + }
28 + // Good enough: both email and phone found.
29 + if !result.emails.isEmpty && !result.phones.isEmpty { break }
30 + }
31 + guard let best else { throw OcrException("OCR vide — reprenez la photo") }
32 + return best
33 + }
34 +
35 + static func score(_ result: OcrResult) -> Int {
36 + var s = result.rawText.count
37 + s += result.emails.count * 200
38 + s += result.phones.count * 150
39 + s += result.urls.count * 100
40 + if result.rawText.contains("@") { s += 50 }
41 + return s
42 + }
43 +}
A ios/Card2vcf/OCR/OcrPostProcessor.swift
+88 -0
@@ -0,0 +1,88 @@
1 +import Foundation
2 +
3 +enum OcrPostProcessor {
4 + private static let emailRegex = KotlinRegex(
5 + #"[\w.+-]+@[\w.-]+\.\w{2,}"#,
6 + options: [.caseInsensitive]
7 + )
8 + private static let urlRegex = KotlinRegex(
9 + #"(?:https?://[\w./?#&=%+-]+)|(?:www\.[\w.-]+\.\w{2,}(?:/[\w./?#&=%+-]*)?)"#,
10 + options: [.caseInsensitive]
11 + )
12 + private static let phoneRegex = KotlinRegex(#"(?:\+?\d[\d\s.\-()]{6,}\d)"#)
13 + private static let oBetweenDigits = KotlinRegex(#"(?<=\d)O(?=\d)"#)
14 + private static let oBeforeDigit = KotlinRegex(#"\bO(?=\d)"#)
15 +
16 + static func process(_ rawText: String) -> OcrResult {
17 + let lines = rawText.kotlinLines()
18 + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
19 + .filter { !$0.isEmpty }
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)
27 + .map { normalizePhone($0) }
28 + .filter { isPlausiblePhone($0) }
29 + .distinctPreservingOrder()
30 + let emails = emailRegex.findAll(joined)
31 + .map { $0.lowercased() }
32 + .distinctPreservingOrder()
33 + let urls = urlRegex.findAll(joined)
34 + .map { normalizeUrl($0) }
35 + .distinctPreservingOrder()
36 + return OcrResult(
37 + rawText: joined,
38 + lines: lines,
39 + phones: phones,
40 + emails: emails,
41 + urls: urls
42 + )
43 + }
44 +
45 + static func enrich(_ base: OcrResult, rawText: String) -> OcrResult {
46 + let extra = process(rawText)
47 + return OcrResult(
48 + rawText: extra.rawText.isBlank ? base.rawText : extra.rawText,
49 + lines: extra.lines.isEmpty ? base.lines : extra.lines,
50 + phones: (base.phones + extra.phones).distinctPreservingOrder(),
51 + emails: (base.emails + extra.emails).distinctPreservingOrder(),
52 + urls: (base.urls + extra.urls).distinctPreservingOrder(),
53 + confidence: base.confidence
54 + )
55 + }
56 +
57 + static func normalizePhone(_ raw: String) -> String {
58 + var s = String(String.UnicodeScalarView(raw.unicodeScalars.filter {
59 + CharacterSet.decimalDigits.contains($0) || $0 == "+"
60 + }))
61 + // +33(0)6... → +336...
62 + if s.hasPrefix("+33") && s.count > 4 {
63 + let idx = s.index(s.startIndex, offsetBy: 3)
64 + if s[idx] == "0" {
65 + s = "+33" + s[s.index(after: idx)...]
66 + }
67 + }
68 + if s.hasPrefix("0033") {
69 + var rest = String(s.dropFirst(4))
70 + if rest.hasPrefix("0") { rest = String(rest.dropFirst()) }
71 + s = "+33" + rest
72 + }
73 + return s
74 + }
75 +
76 + private static func isPlausiblePhone(_ phone: String) -> Bool {
77 + let digits = phone.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
78 + return (10...15).contains(digits.count)
79 + }
80 +
81 + private static func normalizeUrl(_ raw: String) -> String {
82 + var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
83 + while let last = t.last, last == "." || last == "," || last == ";" { t.removeLast() }
84 + let lower = t.lowercased()
85 + if lower.hasPrefix("http://") || lower.hasPrefix("https://") { return t }
86 + return "https://" + t
87 + }
88 +}
A ios/Card2vcf/OCR/OcrResult.swift
+10 -0
@@ -0,0 +1,10 @@
1 +import Foundation
2 +
3 +struct OcrResult: Equatable {
4 + var rawText: String
5 + var lines: [String] = []
6 + var phones: [String] = []
7 + var emails: [String] = []
8 + var urls: [String] = []
9 + var confidence: Float? = nil
10 +}
A ios/Card2vcf/OCR/VisionOcrEngine.swift
+130 -0
@@ -0,0 +1,130 @@
1 +import UIKit
2 +import Vision
3 +import CoreImage
4 +
5 +/// Apple-native replacement for `TesseractOcrEngine` — 100% on-device, no network.
6 +/// Mirrors the Kotlin contract: tries the four quarter-turn orientations
7 +/// (`OcrOrientation.recognizeBest`) and post-processes raw text through
8 +/// `OcrPostProcessor.process` before returning.
9 +final class VisionOcrEngine: OcrEngine {
10 +
11 + /// Tesseract language code -> BCP-47 code for `VNRecognizeTextRequest`.
12 + /// Covers the app's shipped languages (`fra+deu+eng+spa+por+ita+pol`) plus `nld`.
13 + private static let tesseractToBcp47: [String: String] = [
14 + "fra": "fr-FR",
15 + "deu": "de-DE",
16 + "eng": "en-US",
17 + "spa": "es-ES",
18 + "por": "pt-BR",
19 + "ita": "it-IT",
20 + "nld": "nl-NL",
21 + "pol": "pl-PL",
22 + ]
23 +
24 + /// Mirrors `OcrOrientation.angles` = [0, 90, 180, 270] (clockwise).
25 + private static let orientations: [CGImagePropertyOrientation] = [.up, .right, .down, .left]
26 +
27 + private let recognitionLanguages: [String]
28 +
29 + /// `languages` keeps the Kotlin constructor contract ("fra+deu+...").
30 + /// Codes Vision cannot handle on this OS version are dropped at runtime.
31 + init(languages: String = "fra+deu+eng+spa+por+ita+pol") {
32 + let requested = languages
33 + .split(separator: "+")
34 + .compactMap { Self.tesseractToBcp47[String($0).lowercased()] }
35 + recognitionLanguages = Self.filterSupported(requested)
36 + }
37 +
38 + func recognize(_ image: UIImage) async throws -> OcrResult {
39 + guard let cgImage = image.cgImage ?? Self.renderCGImage(image) else {
40 + throw OcrException("Image invalide — impossible de décoder la capture")
41 + }
42 + var best: OcrResult?
43 + var bestScore = Int.min
44 + for orientation in Self.orientations {
45 + guard let result = try? await recognizeOnce(cgImage: cgImage, orientation: orientation) else {
46 + continue
47 + }
48 + let score = OcrOrientation.score(result)
49 + if score > bestScore {
50 + bestScore = score
51 + best = result
52 + }
53 + // Good enough: email + phone found (same early-exit as Kotlin).
54 + if !result.emails.isEmpty && !result.phones.isEmpty { break }
55 + }
56 + guard let best else { throw OcrException("OCR vide — reprenez la photo") }
57 + return best
58 + }
59 +
60 + /// Mirrors `TesseractOcrEngine.recognizeOnce`: raw text -> OcrPostProcessor.
61 + private func recognizeOnce(
62 + cgImage: CGImage,
63 + orientation: CGImagePropertyOrientation
64 + ) async throws -> OcrResult {
65 + let raw = try await performVision(cgImage: cgImage, orientation: orientation)
66 + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
67 + if trimmed.isEmpty { throw OcrException("OCR vide") }
68 + return OcrPostProcessor.process(trimmed)
69 + }
70 +
71 + private func performVision(
72 + cgImage: CGImage,
73 + orientation: CGImagePropertyOrientation
74 + ) async throws -> String {
75 + let languages = recognitionLanguages
76 + return try await withCheckedThrowingContinuation { continuation in
77 + DispatchQueue.global(qos: .userInitiated).async {
78 + let request = VNRecognizeTextRequest()
79 + request.recognitionLevel = .accurate
80 + request.usesLanguageCorrection = true
81 + if !languages.isEmpty {
82 + request.recognitionLanguages = languages
83 + }
84 + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: orientation)
85 + do {
86 + try handler.perform([request])
87 + continuation.resume(returning: Self.joinTopToBottom(request.results ?? []))
88 + } catch {
89 + continuation.resume(throwing: OcrException("Vision OCR a échoué", underlying: error))
90 + }
91 + }
92 + }
93 + }
94 +
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 {
98 + observations
99 + .compactMap { observation -> (y: CGFloat, x: CGFloat, text: String)? in
100 + guard let candidate = observation.topCandidates(1).first else { return nil }
101 + let box = observation.boundingBox
102 + return (box.midY, box.minX, candidate.string)
103 + }
104 + .sorted { a, b in
105 + if abs(a.y - b.y) > 0.01 { return a.y > b.y }
106 + return a.x < b.x
107 + }
108 + .map(\.text)
109 + .joined(separator: "\n")
110 + }
111 +
112 + /// Keeps only languages Vision supports on this OS (exact code, then base-language match).
113 + private static func filterSupported(_ requested: [String]) -> [String] {
114 + let probe = VNRecognizeTextRequest()
115 + probe.recognitionLevel = .accurate
116 + guard let supported = try? probe.supportedRecognitionLanguages() else { return requested }
117 + let exact = Set(supported.map { $0.lowercased() })
118 + let bases = Set(supported.map { $0.split(separator: "-").first.map(String.init)?.lowercased() ?? "" })
119 + return requested.filter { code in
120 + exact.contains(code.lowercased())
121 + || bases.contains(code.split(separator: "-").first.map(String.init)?.lowercased() ?? "")
122 + }
123 + }
124 +
125 + /// Renders CIImage-backed UIImages to a CGImage.
126 + private static func renderCGImage(_ image: UIImage) -> CGImage? {
127 + guard let ciImage = image.ciImage else { return nil }
128 + return CIContext().createCGImage(ciImage, from: ciImage.extent)
129 + }
130 +}
A ios/Card2vcf/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
+0 -0
A ios/Card2vcf/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+11 -0
@@ -0,0 +1,11 @@
1 +{
2 + "images" : [
3 + {
4 + "filename" : "AppIcon.png",
5 + "idiom" : "universal",
6 + "platform" : "ios",
7 + "size" : "1024x1024"
8 + }
9 + ],
10 + "info" : { "author" : "xcode", "version" : 1 }
11 +}
A ios/Card2vcf/Resources/Assets.xcassets/Contents.json
+3 -0
@@ -0,0 +1,3 @@
1 +{
2 + "info" : { "author" : "xcode", "version" : 1 }
3 +}
A ios/Card2vcf/Scan/DocumentScannerView.swift
+103 -0
@@ -0,0 +1,103 @@
1 +import SwiftUI
2 +import VisionKit
3 +import PhotosUI
4 +
5 +/// SwiftUI wrapper around `VNDocumentCameraViewController`.
6 +/// VisionKit performs capture + perspective correction on-device;
7 +/// only the first scanned page is used (one business card per scan).
8 +struct DocumentScannerView: UIViewControllerRepresentable {
9 +
10 + enum Outcome {
11 + case page(UIImage)
12 + case cancelled
13 + case failed(Error)
14 + }
15 +
16 + let completion: (Outcome) -> Void
17 +
18 + /// False in the simulator — use `PhotoPickerView` there.
19 + static var isSupported: Bool { VNDocumentCameraViewController.isSupported }
20 +
21 + func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
22 + let controller = VNDocumentCameraViewController()
23 + controller.delegate = context.coordinator
24 + return controller
25 + }
26 +
27 + func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {}
28 +
29 + func makeCoordinator() -> Coordinator { Coordinator(completion: completion) }
30 +
31 + final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
32 + private let completion: (Outcome) -> Void
33 +
34 + init(completion: @escaping (Outcome) -> Void) {
35 + self.completion = completion
36 + }
37 +
38 + func documentCameraViewController(
39 + _ controller: VNDocumentCameraViewController,
40 + didFinishWith scan: VNDocumentCameraScan
41 + ) {
42 + guard scan.pageCount > 0 else {
43 + completion(.cancelled)
44 + return
45 + }
46 + completion(.page(scan.imageOfPage(at: 0)))
47 + }
48 +
49 + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
50 + completion(.cancelled)
51 + }
52 +
53 + func documentCameraViewController(
54 + _ controller: VNDocumentCameraViewController,
55 + didFailWithError error: Error
56 + ) {
57 + completion(.failed(error))
58 + }
59 + }
60 +}
61 +
62 +/// `PHPickerViewController` wrapper so the scan flow can be exercised in the
63 +/// simulator with an imported image. Completion receives nil on cancel or when
64 +/// the selected asset cannot be loaded as a UIImage.
65 +struct PhotoPickerView: UIViewControllerRepresentable {
66 +
67 + let completion: (UIImage?) -> Void
68 +
69 + func makeUIViewController(context: Context) -> PHPickerViewController {
70 + var configuration = PHPickerConfiguration()
71 + configuration.filter = .images
72 + configuration.selectionLimit = 1
73 + let controller = PHPickerViewController(configuration: configuration)
74 + controller.delegate = context.coordinator
75 + return controller
76 + }
77 +
78 + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
79 +
80 + func makeCoordinator() -> Coordinator { Coordinator(completion: completion) }
81 +
82 + final class Coordinator: NSObject, PHPickerViewControllerDelegate {
83 + private let completion: (UIImage?) -> Void
84 +
85 + init(completion: @escaping (UIImage?) -> Void) {
86 + self.completion = completion
87 + }
88 +
89 + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
90 + guard let provider = results.first?.itemProvider,
91 + provider.canLoadObject(ofClass: UIImage.self) else {
92 + completion(nil)
93 + return
94 + }
95 + let completion = completion
96 + provider.loadObject(ofClass: UIImage.self) { object, _ in
97 + DispatchQueue.main.async {
98 + completion(object as? UIImage)
99 + }
100 + }
101 + }
102 + }
103 +}
A ios/Card2vcf/Scan/ImageOrientation.swift
+122 -0
@@ -0,0 +1,122 @@
1 +import UIKit
2 +import ImageIO
3 +
4 +/// Port of android `scan/ImageOrientation.kt`.
5 +/// Normalizes a photo's orientation before scan/OCR:
6 +/// - EXIF rotations (90/180/270) and mirrored variants
7 +/// - camera rotation fallback when EXIF is absent/normal
8 +/// - EXIF and camera rotation are never applied together (no double rotation)
9 +enum ImageOrientation {
10 +
11 + struct OrientedBitmap {
12 + let bitmap: UIImage
13 + /// Applied quarter-turn degrees (0/90/180/270), mirrors excluded.
14 + let appliedRotationDegrees: Int
15 + /// true when the correction came from EXIF, false for the camera fallback.
16 + let fromExif: Bool
17 + let exifOrientation: CGImagePropertyOrientation
18 + }
19 +
20 + /// `UIImage(data:)` maps the JPEG EXIF orientation onto `imageOrientation`.
21 + static func fromJpegBytes(_ data: Data, cameraRotationDegrees: Int = 0) -> OrientedBitmap? {
22 + guard let image = UIImage(data: data) else { return nil }
23 + return orient(image, cameraRotationDegrees: cameraRotationDegrees)
24 + }
25 +
26 + static func fromFile(_ url: URL, cameraRotationDegrees: Int = 0) -> OrientedBitmap? {
27 + guard let data = try? Data(contentsOf: url) else { return nil }
28 + return fromJpegBytes(data, cameraRotationDegrees: cameraRotationDegrees)
29 + }
30 +
31 + /// Applies EXIF when present and not normal, otherwise the camera rotation.
32 + static func orient(_ image: UIImage, cameraRotationDegrees: Int = 0) -> OrientedBitmap {
33 + let exifOrientation = cgOrientation(from: image.imageOrientation)
34 + if exifOrientation != .up {
35 + return OrientedBitmap(
36 + bitmap: bakeOrientation(image),
37 + appliedRotationDegrees: exifRotationDegrees(exifOrientation),
38 + fromExif: true,
39 + exifOrientation: exifOrientation
40 + )
41 + }
42 + let deg = normalizeDegrees(cameraRotationDegrees)
43 + return OrientedBitmap(
44 + bitmap: deg == 0 ? image : rotate(image, degrees: deg),
45 + appliedRotationDegrees: deg,
46 + fromExif: false,
47 + exifOrientation: exifOrientation
48 + )
49 + }
50 +
51 + /// Kotlin `exifRotationDegrees` — rotation component per EXIF value.
52 + static func exifRotationDegrees(_ orientation: CGImagePropertyOrientation) -> Int {
53 + switch orientation {
54 + case .right, .leftMirrored: // EXIF 6 (rotate 90) / 5 (transpose)
55 + return 90
56 + case .down, .downMirrored: // EXIF 3 (rotate 180) / 4 (flip vertical)
57 + return 180
58 + case .left, .rightMirrored: // EXIF 8 (rotate 270) / 7 (transverse)
59 + return 270
60 + case .up, .upMirrored:
61 + return 0
62 + }
63 + }
64 +
65 + /// Kotlin `normalizeDegrees` — quantizes any angle to the nearest quarter turn.
66 + static func normalizeDegrees(_ degrees: Int) -> Int {
67 + var d = degrees % 360
68 + if d < 0 { d += 360 }
69 + switch d {
70 + case 45..<135: return 90
71 + case 135..<225: return 180
72 + case 225..<315: return 270
73 + default: return 0
74 + }
75 + }
76 +
77 + static func cgOrientation(from orientation: UIImage.Orientation) -> CGImagePropertyOrientation {
78 + switch orientation {
79 + case .up: return .up
80 + case .upMirrored: return .upMirrored
81 + case .down: return .down
82 + case .downMirrored: return .downMirrored
83 + case .left: return .left
84 + case .leftMirrored: return .leftMirrored
85 + case .right: return .right
86 + case .rightMirrored: return .rightMirrored
87 + @unknown default: return .up
88 + }
89 + }
90 +
91 + /// Kotlin `applyExifTransform` equivalent: bakes the orientation (rotation and
92 + /// mirror) into the pixels so downstream Vision/CI passes see upright data.
93 + static func bakeOrientation(_ image: UIImage) -> UIImage {
94 + guard image.imageOrientation != .up else { return image }
95 + let format = UIGraphicsImageRendererFormat.default()
96 + format.scale = image.scale
97 + return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
98 + image.draw(in: CGRect(origin: .zero, size: image.size))
99 + }
100 + }
101 +
102 + /// Clockwise quarter-turn rotation (Kotlin `rotate`).
103 + static func rotate(_ image: UIImage, degrees: Int) -> UIImage {
104 + let swaps = degrees == 90 || degrees == 270
105 + let size = swaps
106 + ? CGSize(width: image.size.height, height: image.size.width)
107 + : image.size
108 + let format = UIGraphicsImageRendererFormat.default()
109 + format.scale = image.scale
110 + return UIGraphicsImageRenderer(size: size, format: format).image { context in
111 + let cg = context.cgContext
112 + cg.translateBy(x: size.width / 2, y: size.height / 2)
113 + cg.rotate(by: CGFloat(degrees) * .pi / 180)
114 + image.draw(in: CGRect(
115 + x: -image.size.width / 2,
116 + y: -image.size.height / 2,
117 + width: image.size.width,
118 + height: image.size.height
119 + ))
120 + }
121 + }
122 +}
A ios/Card2vcf/Scan/ScanEngine.swift
+53 -0
@@ -0,0 +1,53 @@
1 +import UIKit
2 +
3 +/// Port of android `scan/ScanEngine.kt`.
4 +/// Coordinates are image pixels with a top-left origin (same as Android).
5 +struct ScanCorners {
6 + let topLeft: CGPoint
7 + let topRight: CGPoint
8 + let bottomRight: CGPoint
9 + let bottomLeft: CGPoint
10 +}
11 +
12 +struct ScanResult {
13 + let bitmap: UIImage
14 + let angleDegrees: Float
15 + let confidence: Float
16 + let corners: ScanCorners?
17 +
18 + init(
19 + bitmap: UIImage,
20 + angleDegrees: Float = 0,
21 + confidence: Float = 1,
22 + corners: ScanCorners? = nil
23 + ) {
24 + self.bitmap = bitmap
25 + self.angleDegrees = angleDegrees
26 + self.confidence = confidence
27 + self.corners = corners
28 + }
29 +}
30 +
31 +protocol ScanEngine {
32 + func scan(_ source: UIImage, manualCorners: ScanCorners?) async throws -> ScanResult
33 +}
34 +
35 +extension ScanEngine {
36 + /// Kotlin default parameter `manualCorners = null`.
37 + func scan(_ source: UIImage) async throws -> ScanResult {
38 + try await scan(source, manualCorners: nil)
39 + }
40 +}
41 +
42 +/// Port of Kotlin `ScanException`.
43 +struct ScanException: Error, LocalizedError {
44 + let message: String
45 + let underlying: Error?
46 +
47 + init(_ message: String, underlying: Error? = nil) {
48 + self.message = message
49 + self.underlying = underlying
50 + }
51 +
52 + var errorDescription: String? { message }
53 +}
A ios/Card2vcf/Scan/ScanPipeline.swift
+55 -0
@@ -0,0 +1,55 @@
1 +import UIKit
2 +
3 +/// Image provenance. VisionKit already returns perspective-corrected pages;
4 +/// picker images still need the `VisionScanEngine` pass.
5 +enum ScanSource {
6 + case documentCamera
7 + case photoLibrary
8 +}
9 +
10 +/// Orchestrates the Android `ScanCarteViewModel.processCapture` chain:
11 +/// (scan for picker images) -> orientation normalize -> OCR -> heuristic structuring.
12 +struct ScanPipeline {
13 +
14 + /// iOS counterpart of Android `ContactDraftUi`.
15 + struct Output {
16 + let ocrText: String
17 + let draft: ContactCard
18 + /// Corrected image for the draft preview (Android `ContactDraftUi.preview`).
19 + let preview: UIImage
20 + }
21 +
22 + let scanEngine: ScanEngine
23 + let ocrEngine: OcrEngine
24 +
25 + init(scanEngine: ScanEngine = VisionScanEngine(), ocrEngine: OcrEngine = VisionOcrEngine()) {
26 + self.scanEngine = scanEngine
27 + self.ocrEngine = ocrEngine
28 + }
29 +
30 + func process(_ image: UIImage, source: ScanSource) async throws -> Output {
31 + // Orientation first: UIImages carry orientation metadata that must be baked
32 + // into pixels before rectangle detection / warping (Android normalizes at
33 + // capture-decode time, before ScanCarteViewModel.processCapture runs).
34 + let oriented = ImageOrientation.orient(image).bitmap
35 +
36 + let corrected: UIImage
37 + switch source {
38 + case .documentCamera:
39 + // VisionKit already cropped + perspective-corrected the page.
40 + corrected = oriented
41 + case .photoLibrary:
42 + corrected = try await scanEngine.scan(oriented).bitmap
43 + }
44 +
45 + // VisionOcrEngine runs OcrPostProcessor on the raw text internally,
46 + // matching the TesseractOcrEngine contract.
47 + let ocr = try await ocrEngine.recognize(corrected)
48 +
49 + // Android calls ContactDraftMerge.merge(null, ocr); with a nil LLM card
50 + // that reduces to the deterministic heuristics over the OCR result.
51 + let draft = ContactHeuristicParser.parse(ocr)
52 +
53 + return Output(ocrText: ocr.rawText, draft: draft, preview: corrected)
54 + }
55 +}
A ios/Card2vcf/Scan/VisionScanEngine.swift
+137 -0
@@ -0,0 +1,137 @@
1 +import UIKit
2 +import Vision
3 +import CoreImage
4 +
5 +/// Apple-native replacement for `OpenCvScanEngine`:
6 +/// `VNDetectRectanglesRequest` finds the card quad, `CIPerspectiveCorrection` warps it.
7 +/// Mirrors the Kotlin behavior: manual corners win (confidence 1), detected quad
8 +/// scores 0.8, and when nothing is found the near-full-frame fallback (2% margin)
9 +/// is warped with confidence 0.4 instead of failing.
10 +final class VisionScanEngine: ScanEngine {
11 +
12 + func scan(_ source: UIImage, manualCorners: ScanCorners?) async throws -> ScanResult {
13 + guard let cgImage = source.cgImage ?? Self.renderCGImage(source) else {
14 + throw ScanException("Image invalide — impossible de décoder la photo")
15 + }
16 + let width = CGFloat(cgImage.width)
17 + let height = CGFloat(cgImage.height)
18 +
19 + let corners: ScanCorners
20 + let confidence: Float
21 + if let manualCorners {
22 + corners = manualCorners
23 + confidence = 1
24 + } else if let detected = await detectCardCorners(cgImage: cgImage, width: width, height: height) {
25 + corners = detected
26 + confidence = 0.8
27 + } else {
28 + corners = Self.fullFrameCorners(width: width, height: height)
29 + confidence = 0.4
30 + }
31 +
32 + let warped = try Self.perspectiveCorrect(cgImage: cgImage, corners: corners, imageHeight: height)
33 + return ScanResult(
34 + bitmap: warped,
35 + angleDegrees: Self.estimateSkewAngle(corners),
36 + confidence: confidence,
37 + corners: corners
38 + )
39 + }
40 +
41 + /// Returns detected corners, or nil when no plausible quad was found
42 + /// (Kotlin `detectDocumentCorners` null -> fallback path, never fatal).
43 + private func detectCardCorners(
44 + cgImage: CGImage,
45 + width: CGFloat,
46 + height: CGFloat
47 + ) async -> ScanCorners? {
48 + await withCheckedContinuation { continuation in
49 + DispatchQueue.global(qos: .userInitiated).async {
50 + let request = VNDetectRectanglesRequest()
51 + // Business cards: ~0.55 h/w landscape, up to square-ish when portrait.
52 + request.minimumAspectRatio = 0.3
53 + request.maximumAspectRatio = 1.0
54 + request.minimumSize = 0.2
55 + request.minimumConfidence = 0.6
56 + request.maximumObservations = 5
57 + request.quadratureTolerance = 20
58 + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up)
59 + do {
60 + try handler.perform([request])
61 + } catch {
62 + continuation.resume(returning: nil)
63 + return
64 + }
65 + guard let best = (request.results ?? []).max(by: { $0.confidence < $1.confidence }) else {
66 + continuation.resume(returning: nil)
67 + return
68 + }
69 + // Vision points are normalized, origin bottom-left -> pixels, origin top-left.
70 + func toPixels(_ p: CGPoint) -> CGPoint {
71 + CGPoint(x: p.x * width, y: (1 - p.y) * height)
72 + }
73 + continuation.resume(returning: ScanCorners(
74 + topLeft: toPixels(best.topLeft),
75 + topRight: toPixels(best.topRight),
76 + bottomRight: toPixels(best.bottomRight),
77 + bottomLeft: toPixels(best.bottomLeft)
78 + ))
79 + }
80 + }
81 + }
82 +
83 + /// Kotlin `fullFrameCorners(marginRatio = 0.02)`.
84 + static func fullFrameCorners(
85 + width: CGFloat,
86 + height: CGFloat,
87 + marginRatio: CGFloat = 0.02
88 + ) -> ScanCorners {
89 + let mx = width * marginRatio
90 + let my = height * marginRatio
91 + let w = width - 1
92 + let h = height - 1
93 + return ScanCorners(
94 + topLeft: CGPoint(x: mx, y: my),
95 + topRight: CGPoint(x: w - mx, y: my),
96 + bottomRight: CGPoint(x: w - mx, y: h - my),
97 + bottomLeft: CGPoint(x: mx, y: h - my)
98 + )
99 + }
100 +
101 + /// Kotlin `perspectiveCorrect` equivalent via CIPerspectiveCorrection.
102 + static func perspectiveCorrect(
103 + cgImage: CGImage,
104 + corners: ScanCorners,
105 + imageHeight: CGFloat
106 + ) throws -> UIImage {
107 + // ScanCorners use a top-left origin; Core Image uses bottom-left.
108 + func ciPoint(_ p: CGPoint) -> CIVector {
109 + CIVector(x: p.x, y: imageHeight - p.y)
110 + }
111 + guard let filter = CIFilter(name: "CIPerspectiveCorrection") else {
112 + throw ScanException("CIPerspectiveCorrection indisponible")
113 + }
114 + filter.setValue(CIImage(cgImage: cgImage), forKey: kCIInputImageKey)
115 + filter.setValue(ciPoint(corners.topLeft), forKey: "inputTopLeft")
116 + filter.setValue(ciPoint(corners.topRight), forKey: "inputTopRight")
117 + filter.setValue(ciPoint(corners.bottomRight), forKey: "inputBottomRight")
118 + filter.setValue(ciPoint(corners.bottomLeft), forKey: "inputBottomLeft")
119 + guard let output = filter.outputImage,
120 + let corrected = CIContext().createCGImage(output, from: output.extent) else {
121 + throw ScanException("Correction de perspective impossible")
122 + }
123 + return UIImage(cgImage: corrected)
124 + }
125 +
126 + /// Kotlin `estimateSkewAngle`: angle of the top edge, top-left-origin coordinates.
127 + static func estimateSkewAngle(_ corners: ScanCorners) -> Float {
128 + let dx = corners.topRight.x - corners.topLeft.x
129 + let dy = corners.topRight.y - corners.topLeft.y
130 + return Float(atan2(dy, dx) * 180 / .pi)
131 + }
132 +
133 + private static func renderCGImage(_ image: UIImage) -> CGImage? {
134 + guard let ciImage = image.ciImage else { return nil }
135 + return CIContext().createCGImage(ciImage, from: ciImage.extent)
136 + }
137 +}
A ios/Card2vcf/Sync/AgendaSyncCoordinator.swift
+507 -0
@@ -0,0 +1,507 @@
1 +import Foundation
2 +
3 +/// Reservation conflict (HTTP 409) awaiting a user decision: keep it or `abandonLocalReservation`.
4 +struct AgendaConflict: Equatable {
5 + var localReservationId: Int64
6 + var message: String? = nil
7 +}
8 +
9 +/// Calendar↔local-store bridge and DTO↔entity mapping for RDV / reservations / unavailabilities.
10 +/// Extracted from `SyncEngine` to keep the CRM+agenda orchestration readable; `SyncEngine`
11 +/// remains the sole owner of the `SyncOpEntity` queue (push) and of the watermark.
12 +final class AgendaSyncCoordinator {
13 + /// `CalendarBinding.kind` of the « Mes RDV » binding; also used as `SyncOpEntity.entityType`.
14 + static let kindRdv = "rdv"
15 + static let entityReservation = "reservation"
16 + private static let statutActive = "active"
17 +
18 + private let db: CrmDatabase
19 +
20 + init(db: CrmDatabase) {
21 + self.db = db
22 + }
23 +
24 + /// `salle:id,materiel:id,...` from non-`rdv` bindings that carry a `serverResourceId`.
25 + func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? {
26 + Self.ressourcesQuery(bindings)
27 + }
28 +
29 + /// Usable without an instance (status banner).
30 + static func ressourcesQuery(_ bindings: [CalendarBinding]) -> String? {
31 + let parts = bindings
32 + .filter { $0.kind != kindRdv && $0.serverResourceId != nil }
33 + .map { "\($0.kind):\($0.serverResourceId!)" }
34 + return parts.isEmpty ? nil : parts.joined(separator: ",")
35 + }
36 +
37 + // ---- Calendar -> local store (events created/edited in the system calendar app) ----
38 +
39 + func agendaToRoom(bindings: [CalendarBinding], bridge: CalendarBridge) async throws {
40 + for binding in bindings {
41 + guard let calendarId = binding.androidCalendarId else { continue }
42 + if binding.kind == Self.kindRdv {
43 + try await agendaEventsToRdv(calendarId: calendarId, bridge: bridge)
44 + } else {
45 + try await agendaEventsToReservation(binding: binding, calendarId: calendarId, bridge: bridge)
46 + }
47 + }
48 + }
49 +
50 + private func agendaEventsToRdv(calendarId: Int64, bridge: CalendarBridge) async throws {
51 + let events = try bridge.listEvents(calendarId: calendarId)
52 + let presentEventIds = Set(events.compactMap { $0.eventId })
53 +
54 + for snap in events {
55 + guard let eventId = snap.eventId else { continue }
56 + let local = try await db.rdvDao.getByCalendarEventId(eventId)
57 + guard let local else {
58 + var entity = RdvEntity()
59 + entity.titre = snap.title
60 + entity.description = snap.descriptionBody
61 + entity.debutMs = snap.debutMs
62 + entity.finMs = snap.finMs
63 + entity.calendarEventId = eventId
64 + entity.updatedAt = Self.currentMillis()
65 + entity.dirtyLocal = true
66 + let localId = try await db.rdvDao.upsert(entity)
67 + try await enqueueOp(
68 + entityType: Self.kindRdv,
69 + op: "create",
70 + localId: localId,
71 + serverId: nil,
72 + payloadJson: rdvPayload(entity)
73 + )
74 + continue
75 + }
76 + if !rdvDiffers(local, from: snap) { continue }
77 + var updated = local
78 + updated.titre = snap.title
79 + updated.description = snap.descriptionBody
80 + updated.debutMs = snap.debutMs
81 + updated.finMs = snap.finMs
82 + updated.updatedAt = Self.currentMillis()
83 + updated.dirtyLocal = true
84 + try await db.rdvDao.upsert(updated)
85 + try await enqueueOp(
86 + entityType: Self.kindRdv,
87 + op: local.serverId == nil ? "create" : "update",
88 + localId: local.localId,
89 + serverId: local.serverId,
90 + payloadJson: rdvPayload(updated)
91 + )
92 + }
93 +
94 + // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side).
95 + for rdv in try await db.rdvDao.listAll() {
96 + guard let eventId = rdv.calendarEventId else { continue }
97 + if presentEventIds.contains(eventId) { continue }
98 + if let serverId = rdv.serverId {
99 + try await enqueueOp(
100 + entityType: Self.kindRdv,
101 + op: "delete",
102 + localId: rdv.localId,
103 + serverId: serverId,
104 + payloadJson: "{}"
105 + )
106 + }
107 + try await db.rdvDao.deleteByLocalId(rdv.localId)
108 + }
109 + }
110 +
111 + private func rdvDiffers(_ entity: RdvEntity, from snap: CalendarEventSnapshot) -> Bool {
112 + entity.titre != snap.title
113 + || entity.description != snap.descriptionBody
114 + || entity.debutMs != snap.debutMs
115 + || entity.finMs != snap.finMs
116 + }
117 +
118 + private func agendaEventsToReservation(
119 + binding: CalendarBinding,
120 + calendarId: Int64,
121 + bridge: CalendarBridge
122 + ) async throws {
123 + guard let cibleId = binding.serverResourceId else { return }
124 + let cibleType = binding.kind
125 + let events = try bridge.listEvents(calendarId: calendarId)
126 + let presentEventIds = Set(events.compactMap { $0.eventId })
127 +
128 + for snap in events {
129 + guard let eventId = snap.eventId else { continue }
130 + if CalendarEventTitles.isIndispoTitle(snap.title) { continue } // read-only mirror, no push
131 + let local = try await db.reservationDao.getByCalendarEventId(eventId)
132 + guard let local else {
133 + var entity = ReservationEntity()
134 + entity.cibleType = cibleType
135 + entity.cibleId = cibleId
136 + entity.debutMs = snap.debutMs
137 + entity.finMs = snap.finMs
138 + entity.motif = snap.descriptionBody
139 + entity.calendarEventId = eventId
140 + entity.updatedAt = Self.currentMillis()
141 + entity.dirtyLocal = true
142 + let localId = try await db.reservationDao.upsert(entity)
143 + try await enqueueOp(
144 + entityType: Self.entityReservation,
145 + op: "create",
146 + localId: localId,
147 + serverId: nil,
148 + payloadJson: reservationPayload(entity)
149 + )
150 + continue
151 + }
152 + if local.conflictPending { continue } // awaiting user decision, do not re-push
153 + if !reservationDiffers(local, from: snap) { continue }
154 + var updated = local
155 + updated.debutMs = snap.debutMs
156 + updated.finMs = snap.finMs
157 + updated.motif = snap.descriptionBody
158 + updated.updatedAt = Self.currentMillis()
159 + updated.dirtyLocal = true
160 + try await db.reservationDao.upsert(updated)
161 + try await enqueueOp(
162 + entityType: Self.entityReservation,
163 + op: local.serverId == nil ? "create" : "update",
164 + localId: local.localId,
165 + serverId: local.serverId,
166 + payloadJson: reservationPayload(updated)
167 + )
168 + }
169 +
170 + // Event deleted in the system calendar app: propagate the deletion (SyncOp when known server-side).
171 + for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
172 + guard let eventId = reservation.calendarEventId else { continue }
173 + if presentEventIds.contains(eventId) { continue }
174 + if reservation.conflictPending { continue } // user decision pending (see abandonLocalReservation)
175 + if let serverId = reservation.serverId {
176 + try await enqueueOp(
177 + entityType: Self.entityReservation,
178 + op: "delete",
179 + localId: reservation.localId,
180 + serverId: serverId,
181 + payloadJson: cibleRefPayload(cibleType: cibleType, cibleId: cibleId)
182 + )
183 + }
184 + try await db.reservationDao.deleteByLocalId(reservation.localId)
185 + }
186 + }
187 +
188 + private func reservationDiffers(_ entity: ReservationEntity, from snap: CalendarEventSnapshot) -> Bool {
189 + entity.motif != snap.descriptionBody
190 + || entity.debutMs != snap.debutMs
191 + || entity.finMs != snap.finMs
192 + }
193 +
194 + /// Replaces any pending op for this local entity before inserting the new one (avoids piling up).
195 + private func enqueueOp(
196 + entityType: String,
197 + op: String,
198 + localId: Int64,
199 + serverId: String?,
200 + payloadJson: String
201 + ) async throws {
202 + for pending in try await db.syncOpDao.listAll()
203 + where pending.entityType == entityType && pending.localId == localId {
204 + try await db.syncOpDao.deleteById(pending.id)
205 + }
206 + var entity = SyncOpEntity(entityType: entityType, op: op)
207 + entity.payloadJson = payloadJson
208 + entity.localId = localId
209 + entity.serverId = serverId
210 + entity.createdAt = Self.currentMillis()
211 + try await db.syncOpDao.insert(entity)
212 + }
213 +
214 + private func rdvPayload(_ entity: RdvEntity) -> String {
215 + let contactIds = (try? SyncJson.decode([String].self, from: entity.contactIdsJson)) ?? []
216 + return SyncJson.encodeToString(
217 + RdvUpsertRequest(
218 + titre: entity.titre,
219 + description: entity.description,
220 + lieu: entity.lieu,
221 + debut: epochMsToIso(entity.debutMs),
222 + fin: epochMsToIso(entity.finMs),
223 + contactIds: contactIds,
224 + projetId: entity.projetId
225 + )
226 + )
227 + }
228 +
229 + private func reservationPayload(_ entity: ReservationEntity) -> String {
230 + SyncJson.encodeToString(
231 + ReservationUpsertRequest(
232 + debut: epochMsToIso(entity.debutMs),
233 + fin: epochMsToIso(entity.finMs),
234 + motif: entity.motif
235 + )
236 + )
237 + }
238 +
239 + /// Payload of a reservation `delete` SyncOp: carries `(cibleType, cibleId)` so that
240 + /// `SyncEngine` can resolve the server genre/id even after the local entity is gone
241 + /// (see `resolveReservationCible`). Reuses `CibleRessourceDto`, already used for `cible`.
242 + private func cibleRefPayload(cibleType: String, cibleId: String) -> String {
243 + SyncJson.encodeToString(CibleRessourceDto(type: cibleType, id: cibleId))
244 + }
245 +
246 + // ---- Local store -> calendar (after applying the pull) ----
247 +
248 + func roomToAgenda(bindings: [CalendarBinding], bridge: CalendarBridge) async throws {
249 + for binding in bindings {
250 + guard let calendarId = binding.androidCalendarId else { continue }
251 + if binding.kind == Self.kindRdv {
252 + try await rdvToAgenda(calendarId: calendarId, bridge: bridge)
253 + } else {
254 + try await resourceEntitiesToAgenda(binding: binding, calendarId: calendarId, bridge: bridge)
255 + }
256 + }
257 + }
258 +
259 + private func rdvToAgenda(calendarId: Int64, bridge: CalendarBridge) async throws {
260 + for rdv in try await db.rdvDao.listAll() {
261 + let eventId = try bridge.upsertEvent(
262 + calendarId: calendarId,
263 + snap: CalendarEventSnapshot(
264 + title: rdv.titre,
265 + debutMs: rdv.debutMs,
266 + finMs: rdv.finMs,
267 + eventId: rdv.calendarEventId,
268 + serverId: rdv.serverId,
269 + descriptionBody: rdv.description
270 + )
271 + )
272 + if eventId != rdv.calendarEventId {
273 + var updated = rdv
274 + updated.calendarEventId = eventId
275 + try await db.rdvDao.upsert(updated)
276 + }
277 + }
278 + try deleteOrphanEvents(calendarId: calendarId, bridge: bridge, kept: try await rdvKeptIds())
279 + }
280 +
281 + private func resourceEntitiesToAgenda(
282 + binding: CalendarBinding,
283 + calendarId: Int64,
284 + bridge: CalendarBridge
285 + ) async throws {
286 + guard let cibleId = binding.serverResourceId else { return }
287 + let cibleType = binding.kind
288 + for reservation in try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
289 + if reservation.statut != Self.statutActive { continue }
290 + let eventId = try bridge.upsertEvent(
291 + calendarId: calendarId,
292 + snap: CalendarEventSnapshot(
293 + title: reservation.motif.isEmpty ? "Réservation" : reservation.motif,
294 + debutMs: reservation.debutMs,
295 + finMs: reservation.finMs,
296 + eventId: reservation.calendarEventId,
297 + serverId: reservation.serverId
298 + )
299 + )
300 + if eventId != reservation.calendarEventId {
301 + var updated = reservation
302 + updated.calendarEventId = eventId
303 + try await db.reservationDao.upsert(updated)
304 + }
305 + }
306 + for indispo in try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId) {
307 + let baseTitle = nonBlank(indispo.commentaire) ?? indispo.nature ?? "Indisponible"
308 + let eventId = try bridge.upsertEvent(
309 + calendarId: calendarId,
310 + snap: CalendarEventSnapshot(
311 + title: CalendarEventTitles.indispoTitle(baseTitle),
312 + debutMs: indispo.debutMs,
313 + finMs: indispo.finMs,
314 + eventId: indispo.calendarEventId,
315 + serverId: indispo.serverId
316 + )
317 + )
318 + if eventId != indispo.calendarEventId {
319 + var updated = indispo
320 + updated.calendarEventId = eventId
321 + try await db.indisponibiliteDao.upsert(updated)
322 + }
323 + }
324 + try deleteOrphanEvents(
325 + calendarId: calendarId,
326 + bridge: bridge,
327 + kept: try await resourceKeptIds(cibleType: cibleType, cibleId: cibleId)
328 + )
329 + }
330 +
331 + private func nonBlank(_ value: String?) -> String? {
332 + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
333 + return value
334 + }
335 +
336 + // ---- Orphan events (local store deleted the entity, the calendar event must follow) ----
337 +
338 + private func rdvKeptIds() async throws -> (Set<String>, Set<Int64>) {
339 + let entities = try await db.rdvDao.listAll()
340 + return (
341 + Set(entities.compactMap { $0.serverId }),
342 + Set(entities.compactMap { $0.calendarEventId })
343 + )
344 + }
345 +
346 + private func resourceKeptIds(cibleType: String, cibleId: String) async throws -> (Set<String>, Set<Int64>) {
347 + let reservations = try await db.reservationDao.listByCible(cibleType: cibleType, cibleId: cibleId)
348 + .filter { $0.statut == Self.statutActive }
349 + let indispos = try await db.indisponibiliteDao.listByCible(cibleType: cibleType, cibleId: cibleId)
350 + let serverIds = Set(reservations.compactMap { $0.serverId }).union(indispos.compactMap { $0.serverId })
351 + let eventIds = Set(reservations.compactMap { $0.calendarEventId }).union(indispos.compactMap { $0.calendarEventId })
352 + return (serverIds, eventIds)
353 + }
354 +
355 + /// `deleteEvent` for every `card2vcf:serverId=`-tagged event (see `CalendarServerIdCodec`)
356 + /// of `calendarId` whose `serverId` and `eventId` no longer match a local entity (`kept`).
357 + /// Untagged events (not yet pushed, or foreign to card2vcf) are never deleted.
358 + private func deleteOrphanEvents(
359 + calendarId: Int64,
360 + bridge: CalendarBridge,
361 + kept: (Set<String>, Set<Int64>)
362 + ) throws {
363 + for eventId in try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept) {
364 + try bridge.deleteEvent(eventId: eventId)
365 + }
366 + }
367 +
368 + private func orphanEvents(
369 + calendarId: Int64,
370 + bridge: CalendarBridge,
371 + kept: (Set<String>, Set<Int64>)
372 + ) throws -> [Int64] {
373 + let (keptServerIds, keptEventIds) = kept
374 + return try bridge.listEvents(calendarId: calendarId).compactMap { snap in
375 + guard let eventId = snap.eventId else { return nil }
376 + guard let serverId = snap.serverId else { return nil } // not card2vcf-tagged: never managed here
377 + return (keptServerIds.contains(serverId) || keptEventIds.contains(eventId)) ? nil : eventId
378 + }
379 + }
380 +
381 + /// Number of card2vcf-tagged events in the bound calendars with no matching local entity.
382 + func orphanEventCount(bindings: [CalendarBinding], bridge: CalendarBridge) async throws -> Int {
383 + var count = 0
384 + for binding in bindings {
385 + guard let calendarId = binding.androidCalendarId else { continue }
386 + let kept: (Set<String>, Set<Int64>)
387 + if binding.kind == Self.kindRdv {
388 + kept = try await rdvKeptIds()
389 + } else {
390 + guard let cibleId = binding.serverResourceId else { continue }
391 + kept = try await resourceKeptIds(cibleType: binding.kind, cibleId: cibleId)
392 + }
393 + count += try orphanEvents(calendarId: calendarId, bridge: bridge, kept: kept).count
394 + }
395 + return count
396 + }
397 +
398 + // ---- Pull: DTO -> entities (LWW RDV, active reservations, read-only unavailabilities) ----
399 +
400 + func applyRdvPull(_ dto: RendezVousDto) async throws {
401 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
402 + let local = try await db.rdvDao.getByServerId(dto.id)
403 + guard let local else {
404 + var entity = RdvEntity()
405 + entity.serverId = dto.id
406 + entity.titre = dto.titre
407 + entity.description = dto.description
408 + entity.lieu = dto.lieu
409 + entity.debutMs = parseIsoToEpochMs(dto.debut)
410 + entity.finMs = parseIsoToEpochMs(dto.fin)
411 + entity.contactIdsJson = SyncJson.encodeToString(dto.contactIds)
412 + entity.projetId = dto.projetId
413 + entity.updatedAt = remoteTs
414 + try await db.rdvDao.upsert(entity)
415 + return
416 + }
417 + var remoteEntity = local
418 + remoteEntity.titre = dto.titre
419 + remoteEntity.description = dto.description
420 + remoteEntity.lieu = dto.lieu
421 + remoteEntity.debutMs = parseIsoToEpochMs(dto.debut)
422 + remoteEntity.finMs = parseIsoToEpochMs(dto.fin)
423 + remoteEntity.contactIdsJson = SyncJson.encodeToString(dto.contactIds)
424 + remoteEntity.projetId = dto.projetId
425 + remoteEntity.updatedAt = remoteTs
426 + remoteEntity.dirtyLocal = false
427 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
428 + if merged != local {
429 + try await db.rdvDao.upsert(merged)
430 + }
431 + }
432 +
433 + func applyReservationPull(_ dto: ReservationDto) async throws {
434 + let local = try await db.reservationDao.getByServerId(dto.id)
435 + if dto.statut != Self.statutActive {
436 + if local != nil {
437 + try await db.reservationDao.deleteByServerId(dto.id)
438 + }
439 + return
440 + }
441 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
442 + guard let local else {
443 + var entity = ReservationEntity()
444 + entity.serverId = dto.id
445 + entity.cibleType = dto.cible.type
446 + entity.cibleId = dto.cible.id
447 + entity.debutMs = parseIsoToEpochMs(dto.debut)
448 + entity.finMs = parseIsoToEpochMs(dto.fin)
449 + entity.motif = dto.motif
450 + entity.statut = dto.statut
451 + entity.updatedAt = remoteTs
452 + try await db.reservationDao.upsert(entity)
453 + return
454 + }
455 + var remoteEntity = local
456 + remoteEntity.cibleType = dto.cible.type
457 + remoteEntity.cibleId = dto.cible.id
458 + remoteEntity.debutMs = parseIsoToEpochMs(dto.debut)
459 + remoteEntity.finMs = parseIsoToEpochMs(dto.fin)
460 + remoteEntity.motif = dto.motif
461 + remoteEntity.statut = dto.statut
462 + remoteEntity.updatedAt = remoteTs
463 + remoteEntity.dirtyLocal = false
464 + remoteEntity.conflictPending = false
465 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
466 + if merged != local {
467 + try await db.reservationDao.upsert(merged)
468 + }
469 + }
470 +
471 + func applyIndisponibilitePull(_ dto: IndisponibiliteDto) async throws {
472 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
473 + let local = try await db.indisponibiliteDao.getByServerId(dto.id)
474 + var entity = local ?? {
475 + var fresh = IndisponibiliteEntity()
476 + fresh.serverId = dto.id
477 + return fresh
478 + }()
479 + entity.cibleType = dto.cible.type
480 + entity.cibleId = dto.cible.id
481 + entity.debutMs = parseIsoToEpochMs(dto.debut)
482 + entity.finMs = parseIsoToEpochMs(dto.fin)
483 + entity.nature = dto.nature
484 + entity.commentaire = dto.commentaire
485 + entity.updatedAt = remoteTs
486 + try await db.indisponibiliteDao.upsert(entity)
487 + }
488 +
489 + @discardableResult
490 + func applyAgendaTombstone(_ dto: TombstoneDto) async throws -> Bool {
491 + switch dto.entityType {
492 + case "rdv":
493 + try await db.rdvDao.deleteByServerId(dto.id)
494 + case "reservation":
495 + try await db.reservationDao.deleteByServerId(dto.id)
496 + case "indisponibilite":
497 + try await db.indisponibiliteDao.deleteByServerId(dto.id)
498 + default:
499 + return false
500 + }
501 + return true
502 + }
503 +
504 + static func currentMillis() -> Int64 {
505 + Int64(Date().timeIntervalSince1970 * 1000)
506 + }
507 +}
A ios/Card2vcf/Sync/AilianceApi.swift
+102 -0
@@ -0,0 +1,102 @@
1 +import Foundation
2 +
3 +/// HTTP contract used by `SyncEngine`; `AilianceApiClient` implements it, fakes in tests.
4 +protocol AilianceApi {
5 + func authCle(nom: String, motDePasse: String) async -> AilianceApiClient.ApiResult<AuthCleResponse>
6 +
7 + func syncStatus(sinceIso: String, ressourcesQuery: String?) async -> AilianceApiClient.ApiResult<SyncStatusResponse>
8 +
9 + func syncPull(sinceIso: String, ressourcesQuery: String?) async -> AilianceApiClient.ApiResult<SyncPullResponse>
10 +
11 + func createContact(jsonBody: String) async -> AilianceApiClient.ApiResult<String>
12 +
13 + func updateContact(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
14 +
15 + func deleteContact(id: String) async -> AilianceApiClient.ApiResult<Void>
16 +
17 + func createEntreprise(jsonBody: String) async -> AilianceApiClient.ApiResult<String>
18 +
19 + func updateEntreprise(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
20 +
21 + func deleteEntreprise(id: String) async -> AilianceApiClient.ApiResult<Void>
22 +
23 + func createProjet(jsonBody: String) async -> AilianceApiClient.ApiResult<String>
24 +
25 + func updateProjet(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
26 +
27 + func deleteProjet(id: String) async -> AilianceApiClient.ApiResult<Void>
28 +
29 + func createTache(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
30 +
31 + func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
32 +
33 + func deleteTache(projetId: String, tacheId: String) async -> AilianceApiClient.ApiResult<Void>
34 +
35 + func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
36 +
37 + func createInteraction(contactId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
38 +
39 + func listRdv() async -> AilianceApiClient.ApiResult<[RendezVousDto]>
40 +
41 + func createRdv(jsonBody: String) async -> AilianceApiClient.ApiResult<String>
42 +
43 + func getRdv(id: String) async -> AilianceApiClient.ApiResult<RendezVousDto>
44 +
45 + func updateRdv(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
46 +
47 + func deleteRdv(id: String) async -> AilianceApiClient.ApiResult<Void>
48 +
49 + func getRessourcesCatalogue() async -> AilianceApiClient.ApiResult<RessourcesCatalogueDto>
50 +
51 + func listReservations(genre: String, resourceId: String) async -> AilianceApiClient.ApiResult<[ReservationDto]>
52 +
53 + func createReservation(genre: String, resourceId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
54 +
55 + func updateReservation(
56 + genre: String,
57 + resourceId: String,
58 + reservationId: String,
59 + jsonBody: String
60 + ) async -> AilianceApiClient.ApiResult<String>
61 +
62 + func deleteReservation(genre: String, resourceId: String, reservationId: String) async -> AilianceApiClient.ApiResult<Void>
63 +
64 + func listIndisponibilites(genre: String, resourceId: String) async -> AilianceApiClient.ApiResult<[IndisponibiliteDto]>
65 +
66 + func uploadContactCarte(
67 + id: String,
68 + bytes: Data,
69 + filename: String,
70 + contentType: String
71 + ) async -> AilianceApiClient.ApiResult<String>
72 +
73 + func uploadContactPhoto(
74 + id: String,
75 + bytes: Data,
76 + filename: String,
77 + contentType: String
78 + ) async -> AilianceApiClient.ApiResult<String>
79 +
80 + func downloadContactCarte(id: String) async -> AilianceApiClient.ApiResult<Data>
81 +
82 + func downloadContactPhoto(id: String) async -> AilianceApiClient.ApiResult<Data>
83 +}
84 +
85 +/// Kotlin default parameter values, mirrored as overloads.
86 +extension AilianceApi {
87 + func syncStatus(sinceIso: String) async -> AilianceApiClient.ApiResult<SyncStatusResponse> {
88 + await syncStatus(sinceIso: sinceIso, ressourcesQuery: nil)
89 + }
90 +
91 + func syncPull(sinceIso: String) async -> AilianceApiClient.ApiResult<SyncPullResponse> {
92 + await syncPull(sinceIso: sinceIso, ressourcesQuery: nil)
93 + }
94 +
95 + func uploadContactCarte(id: String, bytes: Data, filename: String) async -> AilianceApiClient.ApiResult<String> {
96 + await uploadContactCarte(id: id, bytes: bytes, filename: filename, contentType: "image/jpeg")
97 + }
98 +
99 + func uploadContactPhoto(id: String, bytes: Data, filename: String) async -> AilianceApiClient.ApiResult<String> {
100 + await uploadContactPhoto(id: id, bytes: bytes, filename: filename, contentType: "image/jpeg")
101 + }
102 +}
A ios/Card2vcf/Sync/AilianceApiClient.swift
+388 -0
@@ -0,0 +1,388 @@
1 +import Foundation
2 +
3 +/// URLSession port of the OkHttp `AilianceApiClient`:
4 +/// Bearer auth (except `/api/auth/cle`), JSON bodies, multipart uploads,
5 +/// typed `ApiResult` error mapping (HTTP code + parsed `{message|error}` body).
6 +final class AilianceApiClient: AilianceApi {
7 + enum ApiResult<T> {
8 + case ok(T)
9 + case err(code: Int, message: String)
10 +
11 + var isOk: Bool {
12 + if case .ok = self { return true }
13 + return false
14 + }
15 +
16 + var value: T? {
17 + if case .ok(let value) = self { return value }
18 + return nil
19 + }
20 + }
21 +
22 + private let base: String
23 + private let apiKey: String?
24 + private let session: URLSession
25 +
26 + init(baseUrl: String, apiKey: String? = nil, session: URLSession = AilianceApiClient.defaultSession()) {
27 + var trimmed = baseUrl
28 + while trimmed.hasSuffix("/") { trimmed.removeLast() }
29 + self.base = trimmed
30 + self.apiKey = apiKey
31 + self.session = session
32 + }
33 +
34 + /// No system proxy (OkHttp `Proxy.NO_PROXY` equivalent) ; read timeout 30 s.
35 + static func defaultSession() -> URLSession {
36 + let config = URLSessionConfiguration.ephemeral
37 + config.timeoutIntervalForRequest = 30
38 + config.connectionProxyDictionary = [:]
39 + return URLSession(configuration: config)
40 + }
41 +
42 + // MARK: - AilianceApi
43 +
44 + func authCle(nom: String, motDePasse: String) async -> ApiResult<AuthCleResponse> {
45 + let body = SyncJson.encodeToString(AuthCleRequest(nom: nom, motDePasse: motDePasse))
46 + return await post("/api/auth/cle", body, useBearer: false) { data in
47 + try SyncJson.decoder().decode(AuthCleResponse.self, from: data)
48 + }
49 + }
50 +
51 + func syncStatus(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncStatusResponse> {
52 + await get("/api/sync/status?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in
53 + try SyncJson.decoder().decode(SyncStatusResponse.self, from: data)
54 + }
55 + }
56 +
57 + func syncPull(sinceIso: String, ressourcesQuery: String?) async -> ApiResult<SyncPullResponse> {
58 + await get("/api/sync/pull?since=\(Self.encodeQuery(sinceIso))\(ressourcesQueryParam(ressourcesQuery))") { data in
59 + try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
60 + }
61 + }
62 +
63 + func createContact(jsonBody: String) async -> ApiResult<String> {
64 + await post("/api/contacts", jsonBody, parse: Self.text)
65 + }
66 +
67 + func updateContact(id: String, jsonBody: String) async -> ApiResult<String> {
68 + await put("/api/contacts/\(Self.encodePath(id))", jsonBody, parse: Self.text)
69 + }
70 +
71 + func deleteContact(id: String) async -> ApiResult<Void> {
72 + await delete("/api/contacts/\(Self.encodePath(id))")
73 + }
74 +
75 + func createEntreprise(jsonBody: String) async -> ApiResult<String> {
76 + await post("/api/entreprises", jsonBody, parse: Self.text)
77 + }
78 +
79 + func updateEntreprise(id: String, jsonBody: String) async -> ApiResult<String> {
80 + await put("/api/entreprises/\(Self.encodePath(id))", jsonBody, parse: Self.text)
81 + }
82 +
83 + func deleteEntreprise(id: String) async -> ApiResult<Void> {
84 + await delete("/api/entreprises/\(Self.encodePath(id))")
85 + }
86 +
87 + func createProjet(jsonBody: String) async -> ApiResult<String> {
88 + await post("/api/projets", jsonBody, parse: Self.text)
89 + }
90 +
91 + func updateProjet(id: String, jsonBody: String) async -> ApiResult<String> {
92 + await put("/api/projets/\(Self.encodePath(id))", jsonBody, parse: Self.text)
93 + }
94 +
95 + func deleteProjet(id: String) async -> ApiResult<Void> {
96 + await delete("/api/projets/\(Self.encodePath(id))")
97 + }
98 +
99 + func createTache(projetId: String, jsonBody: String) async -> ApiResult<String> {
100 + await post("/api/projets/\(Self.encodePath(projetId))/taches", jsonBody, parse: Self.text)
101 + }
102 +
103 + func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> {
104 + await put("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))", jsonBody, parse: Self.text)
105 + }
106 +
107 + func deleteTache(projetId: String, tacheId: String) async -> ApiResult<Void> {
108 + await delete("/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))")
109 + }
110 +
111 + func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> ApiResult<String> {
112 + await post(
113 + "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))/deplacer",
114 + jsonBody,
115 + parse: Self.text
116 + )
117 + }
118 +
119 + func createInteraction(contactId: String, jsonBody: String) async -> ApiResult<String> {
120 + await post("/api/contacts/\(Self.encodePath(contactId))/interactions", jsonBody, parse: Self.text)
121 + }
122 +
123 + func listRdv() async -> ApiResult<[RendezVousDto]> {
124 + await get("/api/rdv") { data in
125 + try SyncJson.decoder().decode([RendezVousDto].self, from: data)
126 + }
127 + }
128 +
129 + func createRdv(jsonBody: String) async -> ApiResult<String> {
130 + await post("/api/rdv", jsonBody, parse: Self.text)
131 + }
132 +
133 + func getRdv(id: String) async -> ApiResult<RendezVousDto> {
134 + await get("/api/rdv/\(Self.encodePath(id))") { data in
135 + try SyncJson.decoder().decode(RendezVousDto.self, from: data)
136 + }
137 + }
138 +
139 + func updateRdv(id: String, jsonBody: String) async -> ApiResult<String> {
140 + await put("/api/rdv/\(Self.encodePath(id))", jsonBody, parse: Self.text)
141 + }
142 +
143 + func deleteRdv(id: String) async -> ApiResult<Void> {
144 + await delete("/api/rdv/\(Self.encodePath(id))")
145 + }
146 +
147 + func getRessourcesCatalogue() async -> ApiResult<RessourcesCatalogueDto> {
148 + await get("/api/ressources") { data in
149 + try SyncJson.decoder().decode(RessourcesCatalogueDto.self, from: data)
150 + }
151 + }
152 +
153 + func listReservations(genre: String, resourceId: String) async -> ApiResult<[ReservationDto]> {
154 + await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations") { data in
155 + try SyncJson.decoder().decode([ReservationDto].self, from: data)
156 + }
157 + }
158 +
159 + func createReservation(genre: String, resourceId: String, jsonBody: String) async -> ApiResult<String> {
160 + await post(
161 + "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations",
162 + jsonBody,
163 + parse: Self.text
164 + )
165 + }
166 +
167 + func updateReservation(
168 + genre: String,
169 + resourceId: String,
170 + reservationId: String,
171 + jsonBody: String
172 + ) async -> ApiResult<String> {
173 + await put(
174 + "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))",
175 + jsonBody,
176 + parse: Self.text
177 + )
178 + }
179 +
180 + func deleteReservation(genre: String, resourceId: String, reservationId: String) async -> ApiResult<Void> {
181 + await delete(
182 + "/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/reservations/\(Self.encodePath(reservationId))"
183 + )
184 + }
185 +
186 + func listIndisponibilites(genre: String, resourceId: String) async -> ApiResult<[IndisponibiliteDto]> {
187 + await get("/api/ressources/\(Self.encodePath(genre))/\(Self.encodePath(resourceId))/indisponibilites") { data in
188 + try SyncJson.decoder().decode([IndisponibiliteDto].self, from: data)
189 + }
190 + }
191 +
192 + func uploadContactCarte(
193 + id: String,
194 + bytes: Data,
195 + filename: String,
196 + contentType: String
197 + ) async -> ApiResult<String> {
198 + await uploadMultipart("/api/contacts/\(Self.encodePath(id))/carte", bytes, filename, contentType)
199 + }
200 +
201 + func uploadContactPhoto(
202 + id: String,
203 + bytes: Data,
204 + filename: String,
205 + contentType: String
206 + ) async -> ApiResult<String> {
207 + await uploadMultipart("/api/contacts/\(Self.encodePath(id))/photo", bytes, filename, contentType)
208 + }
209 +
210 + func downloadContactCarte(id: String) async -> ApiResult<Data> {
211 + await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/carte", nil, useBearer: true))
212 + }
213 +
214 + func downloadContactPhoto(id: String) async -> ApiResult<Data> {
215 + await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/photo", nil, useBearer: true))
216 + }
217 +
218 + // MARK: - Internals
219 +
220 + private func uploadMultipart(
221 + _ path: String,
222 + _ bytes: Data,
223 + _ filename: String,
224 + _ contentType: String
225 + ) async -> ApiResult<String> {
226 + let boundary = "Card2vcf-\(UUID().uuidString)"
227 + var body = Data()
228 + body.append(Data("--\(boundary)\r\n".utf8))
229 + body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
230 + body.append(Data("Content-Type: \(contentType)\r\n\r\n".utf8))
231 + body.append(bytes)
232 + body.append(Data("\r\n--\(boundary)--\r\n".utf8))
233 +
234 + guard var request = makeRequest(path) else {
235 + return .err(code: -1, message: "URL invalide : \(base)\(path)")
236 + }
237 + request.httpMethod = "POST"
238 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
239 + request.httpBody = body
240 + if let apiKey, !apiKey.isEmpty {
241 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
242 + }
243 + return await execute(request, parse: Self.text)
244 + }
245 +
246 + private func executeBytes(_ request: URLRequest?) async -> ApiResult<Data> {
247 + guard let request else { return .err(code: -1, message: "URL invalide") }
248 + do {
249 + let (data, response) = try await session.data(for: request)
250 + guard let http = response as? HTTPURLResponse else {
251 + return .err(code: -1, message: "Réponse invalide")
252 + }
253 + if (200..<300).contains(http.statusCode) {
254 + return .ok(data)
255 + }
256 + let text = String(data: data, encoding: .utf8) ?? ""
257 + return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http)))
258 + } catch {
259 + return .err(code: -1, message: networkErrorMessage(error, request: request))
260 + }
261 + }
262 +
263 + private func get<T>(_ path: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> {
264 + await execute(buildRequest("GET", path, nil, useBearer: true), parse: parse)
265 + }
266 +
267 + private func post<T>(
268 + _ path: String,
269 + _ jsonBody: String,
270 + useBearer: Bool = true,
271 + parse: @escaping (Data) throws -> T
272 + ) async -> ApiResult<T> {
273 + await execute(buildRequest("POST", path, jsonBody, useBearer: useBearer), parse: parse)
274 + }
275 +
276 + private func put<T>(_ path: String, _ jsonBody: String, parse: @escaping (Data) throws -> T) async -> ApiResult<T> {
277 + await execute(buildRequest("PUT", path, jsonBody, useBearer: true), parse: parse)
278 + }
279 +
280 + private func delete(_ path: String) async -> ApiResult<Void> {
281 + let result = await execute(buildRequest("DELETE", path, nil, useBearer: true), parse: Self.text)
282 + switch result {
283 + case .ok:
284 + return .ok(())
285 + case .err(let code, let message):
286 + return .err(code: code, message: message)
287 + }
288 + }
289 +
290 + private func makeRequest(_ path: String) -> URLRequest? {
291 + guard let url = URL(string: "\(base)\(path)") else { return nil }
292 + return URLRequest(url: url)
293 + }
294 +
295 + private func buildRequest(_ method: String, _ path: String, _ jsonBody: String?, useBearer: Bool) -> URLRequest? {
296 + guard var request = makeRequest(path) else { return nil }
297 + request.httpMethod = method
298 + if useBearer, let apiKey, !apiKey.isEmpty {
299 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
300 + }
301 + if let jsonBody {
302 + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
303 + request.httpBody = Data(jsonBody.utf8)
304 + }
305 + return request
306 + }
307 +
308 + private func execute<T>(_ request: URLRequest?, parse: (Data) throws -> T) async -> ApiResult<T> {
309 + guard let request else { return .err(code: -1, message: "URL invalide") }
310 + do {
311 + let (data, response) = try await session.data(for: request)
312 + guard let http = response as? HTTPURLResponse else {
313 + return .err(code: -1, message: "Réponse invalide")
314 + }
315 + if (200..<300).contains(http.statusCode) {
316 + return .ok(try parse(data))
317 + }
318 + let text = String(data: data, encoding: .utf8) ?? ""
319 + return .err(code: http.statusCode, message: errorMessage(text, fallback: Self.fallbackMessage(http)))
320 + } catch let error as URLError {
321 + return .err(code: -1, message: networkErrorMessage(error, request: request))
322 + } catch {
323 + return .err(code: -1, message: error.localizedDescription)
324 + }
325 + }
326 +
327 + private func networkErrorMessage(_ error: Error, request: URLRequest) -> String {
328 + let detail = (error as? URLError)?.localizedDescription ?? error.localizedDescription
329 + let refused = detail.lowercased().contains("refused")
330 + || (error as? URLError)?.code == .cannotConnectToHost
331 + let hint = refused
332 + ? " (paquet non reçu par le serveur : vérifiez IP Wi‑Fi du PC, pas de proxy téléphone, même Wi‑Fi)"
333 + : ""
334 + let method = request.httpMethod ?? "GET"
335 + let url = request.url?.absoluteString ?? ""
336 + return "\(detail) → \(method) \(url)\(hint)"
337 + }
338 +
339 + private func errorMessage(_ body: String, fallback: String) -> String {
340 + if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return fallback }
341 + if let err = try? SyncJson.decode(ErrorBody.self, from: body) {
342 + return err.message ?? err.error ?? body
343 + }
344 + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
345 + return trimmed.isEmpty ? fallback : trimmed
346 + }
347 +
348 + private struct ErrorBody: Codable {
349 + var message: String? = nil
350 + var error: String? = nil
351 +
352 + init(from decoder: Decoder) throws {
353 + let c = try decoder.container(keyedBy: CodingKeys.self)
354 + message = try c.decodeIfPresent(String.self, forKey: .message)
355 + error = try c.decodeIfPresent(String.self, forKey: .error)
356 + }
357 + }
358 +
359 + private func ressourcesQueryParam(_ ressourcesQuery: String?) -> String {
360 + guard let ressourcesQuery, !ressourcesQuery.isEmpty else { return "" }
361 + return "&ressources=\(Self.encodeQuery(ressourcesQuery))"
362 + }
363 +
364 + private static func text(_ data: Data) -> String {
365 + String(data: data, encoding: .utf8) ?? ""
366 + }
367 +
368 + private static func fallbackMessage(_ response: HTTPURLResponse) -> String {
369 + HTTPURLResponse.localizedString(forStatusCode: response.statusCode)
370 + }
371 +
372 + /// `java.net.URLEncoder` charset: letters/digits and `-._*` unescaped.
373 + private static let urlEncoderAllowed: CharacterSet = {
374 + var set = CharacterSet.alphanumerics
375 + set.insert(charactersIn: "-._*")
376 + return set
377 + }()
378 +
379 + static func encodeQuery(_ value: String) -> String {
380 + value.addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? value
381 + }
382 +
383 + static func encodePath(_ segment: String) -> String {
384 + segment.split(separator: "/", omittingEmptySubsequences: false)
385 + .map { String($0).addingPercentEncoding(withAllowedCharacters: urlEncoderAllowed) ?? String($0) }
386 + .joined(separator: "/")
387 + }
388 +}
A ios/Card2vcf/Sync/ApiCleCrypto.swift
+559 -0
@@ -0,0 +1,559 @@
1 +import CryptoKit
2 +import Foundation
3 +
4 +/// Kotlin `ApiCleCryptoException`.
5 +struct ApiCleCryptoException: Error {
6 + let message: String
7 + let cause: Error?
8 +
9 + init(_ message: String, cause: Error? = nil) {
10 + self.message = message
11 + self.cause = cause
12 + }
13 +}
14 +
15 +/// Decrypts the Projectiaon API key blob produced by the Rust server:
16 +/// KDF Argon2id (crate `argon2` 0.5 defaults: m=19456 KiB, t=2, p=1, 32-byte key)
17 +/// + XChaCha20-Poly1305, blob layout = 24-byte nonce || ciphertext || 16-byte tag.
18 +/// Same on-disk format as the Android port (BouncyCastle); implemented here with a
19 +/// pure-Swift Argon2id/Blake2b + HChaCha20 and CryptoKit `ChaChaPoly` for the AEAD.
20 +enum ApiCleCrypto {
21 + private static let NONCE_LEN = 24
22 + private static let TAG_LEN = 16
23 + private static let KEY_LEN = 32
24 + private static let SALT_LEN = 16
25 + private static let ARGON2_M_KB = 19456
26 + private static let ARGON2_T = 2
27 + private static let ARGON2_P = 1
28 +
29 + static func decryptApiKey(password: String, saltB64: String, cipherB64: String) throws -> String {
30 + let salt = try decodeB64(saltB64, "sel")
31 + guard salt.count == SALT_LEN else {
32 + throw ApiCleCryptoException("sel invalide")
33 + }
34 +
35 + let blob = try decodeB64(cipherB64, "cle_chiffree")
36 + guard blob.count >= NONCE_LEN + TAG_LEN else {
37 + throw ApiCleCryptoException("blob trop court")
38 + }
39 +
40 + let key = Argon2id.hash(
41 + password: [UInt8](password.utf8),
42 + salt: salt,
43 + timeCost: ARGON2_T,
44 + memoryKiB: ARGON2_M_KB,
45 + parallelism: ARGON2_P,
46 + outputLength: KEY_LEN
47 + )
48 + let nonce = Array(blob[0..<NONCE_LEN])
49 + let ciphertext = Array(blob[NONCE_LEN...])
50 +
51 + let plaintext: Data
52 + do {
53 + plaintext = try XChaCha20Poly1305.open(key: key, nonce24: nonce, ciphertextAndTag: ciphertext)
54 + } catch {
55 + throw ApiCleCryptoException("échec déchiffrement clé API", cause: error)
56 + }
57 +
58 + return String(decoding: plaintext, as: UTF8.self)
59 + }
60 +
61 + private static func decodeB64(_ value: String, _ label: String) throws -> [UInt8] {
62 + guard let data = Data(base64Encoded: value) else {
63 + throw ApiCleCryptoException("\(label) base64 invalide")
64 + }
65 + return [UInt8](data)
66 + }
67 +}
68 +
69 +// MARK: - XChaCha20-Poly1305 (24-byte nonce) via HChaCha20 + CryptoKit ChaChaPoly
70 +
71 +enum XChaCha20Poly1305 {
72 + struct AuthenticationFailure: Error {}
73 +
74 + static func open(key: [UInt8], nonce24: [UInt8], ciphertextAndTag: [UInt8]) throws -> Data {
75 + guard key.count == 32, nonce24.count == 24, ciphertextAndTag.count >= 16 else {
76 + throw AuthenticationFailure()
77 + }
78 + let subkey = hChaCha20(key: key, nonce16: Array(nonce24[0..<16]))
79 + // IETF ChaCha20-Poly1305 nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce.
80 + var ietfNonce = [UInt8](repeating: 0, count: 4)
81 + ietfNonce.append(contentsOf: nonce24[16..<24])
82 +
83 + let ciphertext = Data(ciphertextAndTag.dropLast(16))
84 + let tag = Data(ciphertextAndTag.suffix(16))
85 + let box = try ChaChaPoly.SealedBox(
86 + nonce: ChaChaPoly.Nonce(data: Data(ietfNonce)),
87 + ciphertext: ciphertext,
88 + tag: tag
89 + )
90 + return try ChaChaPoly.open(box, using: SymmetricKey(data: Data(subkey)))
91 + }
92 +
93 + private static func hChaCha20(key: [UInt8], nonce16: [UInt8]) -> [UInt8] {
94 + var state = [UInt32](repeating: 0, count: 16)
95 + state[0] = 0x6170_7865
96 + state[1] = 0x3320_646e
97 + state[2] = 0x7962_2d32
98 + state[3] = 0x6b20_6574
99 + for i in 0..<8 { state[4 + i] = load32(key, 4 * i) }
100 + for i in 0..<4 { state[12 + i] = load32(nonce16, 4 * i) }
101 +
102 + for _ in 0..<10 {
103 + quarterRound(&state, 0, 4, 8, 12)
104 + quarterRound(&state, 1, 5, 9, 13)
105 + quarterRound(&state, 2, 6, 10, 14)
106 + quarterRound(&state, 3, 7, 11, 15)
107 + quarterRound(&state, 0, 5, 10, 15)
108 + quarterRound(&state, 1, 6, 11, 12)
109 + quarterRound(&state, 2, 7, 8, 13)
110 + quarterRound(&state, 3, 4, 9, 14)
111 + }
112 +
113 + var out = [UInt8]()
114 + out.reserveCapacity(32)
115 + for i in [0, 1, 2, 3, 12, 13, 14, 15] {
116 + var word = state[i].littleEndian
117 + withUnsafeBytes(of: &word) { out.append(contentsOf: $0) }
118 + }
119 + return out
120 + }
121 +
122 + private static func load32(_ bytes: [UInt8], _ offset: Int) -> UInt32 {
123 + UInt32(bytes[offset])
124 + | (UInt32(bytes[offset + 1]) << 8)
125 + | (UInt32(bytes[offset + 2]) << 16)
126 + | (UInt32(bytes[offset + 3]) << 24)
127 + }
128 +
129 + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
130 + s[a] = s[a] &+ s[b]; s[d] = rotl(s[d] ^ s[a], 16)
131 + s[c] = s[c] &+ s[d]; s[b] = rotl(s[b] ^ s[c], 12)
132 + s[a] = s[a] &+ s[b]; s[d] = rotl(s[d] ^ s[a], 8)
133 + s[c] = s[c] &+ s[d]; s[b] = rotl(s[b] ^ s[c], 7)
134 + }
135 +
136 + private static func rotl(_ x: UInt32, _ n: UInt32) -> UInt32 {
137 + (x << n) | (x >> (32 - n))
138 + }
139 +}
140 +
141 +// MARK: - Blake2b (unkeyed, variable digest length up to 64 bytes)
142 +
143 +enum Blake2b {
144 + private static let iv: [UInt64] = [
145 + 0x6a09_e667_f3bc_c908, 0xbb67_ae85_84ca_a73b,
146 + 0x3c6e_f372_fe94_f82b, 0xa54f_f53a_5f1d_36f1,
147 + 0x510e_527f_ade6_82d1, 0x9b05_688c_2b3e_6c1f,
148 + 0x1f83_d9ab_fb41_bd6b, 0x5be0_cd19_137e_2179,
149 + ]
150 +
151 + private static let sigma: [[Int]] = [
152 + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
153 + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
154 + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
155 + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
156 + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
157 + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
158 + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
159 + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
160 + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
161 + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
162 + ]
163 +
164 + static func hash(_ input: [UInt8], outLen: Int) -> [UInt8] {
165 + precondition(outLen >= 1 && outLen <= 64)
166 + var h = iv
167 + h[0] ^= 0x0101_0000 ^ UInt64(outLen)
168 +
169 + var t: UInt64 = 0
170 + var offset = 0
171 + let count = input.count
172 + // Process all blocks but the last (which may be partial or empty).
173 + while count - offset > 128 {
174 + t &+= 128
175 + compress(&h, block: input, blockOffset: offset, t: t, last: false)
176 + offset += 128
177 + }
178 + var lastBlock = [UInt8](repeating: 0, count: 128)
179 + let remaining = count - offset
180 + if remaining > 0 {
181 + lastBlock.replaceSubrange(0..<remaining, with: input[offset..<count])
182 + }
183 + t &+= UInt64(remaining)
184 + compress(&h, block: lastBlock, blockOffset: 0, t: t, last: true)
185 +
186 + var out = [UInt8]()
187 + out.reserveCapacity(outLen)
188 + outer: for word in h {
189 + var value = word.littleEndian
190 + withUnsafeBytes(of: &value) { bytes in
191 + for byte in bytes {
192 + if out.count == outLen { return }
193 + out.append(byte)
194 + }
195 + }
196 + if out.count == outLen { break outer }
197 + }
198 + return out
199 + }
200 +
201 + private static func compress(_ h: inout [UInt64], block: [UInt8], blockOffset: Int, t: UInt64, last: Bool) {
202 + var m = [UInt64](repeating: 0, count: 16)
203 + for i in 0..<16 {
204 + let o = blockOffset + 8 * i
205 + m[i] = UInt64(block[o])
206 + | (UInt64(block[o + 1]) << 8)
207 + | (UInt64(block[o + 2]) << 16)
208 + | (UInt64(block[o + 3]) << 24)
209 + | (UInt64(block[o + 4]) << 32)
210 + | (UInt64(block[o + 5]) << 40)
211 + | (UInt64(block[o + 6]) << 48)
212 + | (UInt64(block[o + 7]) << 56)
213 + }
214 +
215 + var v = [UInt64](repeating: 0, count: 16)
216 + for i in 0..<8 { v[i] = h[i] }
217 + for i in 0..<8 { v[8 + i] = iv[i] }
218 + v[12] ^= t
219 + // t high 64 bits always 0 for our input sizes.
220 + if last { v[14] = ~v[14] }
221 +
222 + for round in 0..<12 {
223 + let s = sigma[round % 10]
224 + g(&v, 0, 4, 8, 12, m[s[0]], m[s[1]])
225 + g(&v, 1, 5, 9, 13, m[s[2]], m[s[3]])
226 + g(&v, 2, 6, 10, 14, m[s[4]], m[s[5]])
227 + g(&v, 3, 7, 11, 15, m[s[6]], m[s[7]])
228 + g(&v, 0, 5, 10, 15, m[s[8]], m[s[9]])
229 + g(&v, 1, 6, 11, 12, m[s[10]], m[s[11]])
230 + g(&v, 2, 7, 8, 13, m[s[12]], m[s[13]])
231 + g(&v, 3, 4, 9, 14, m[s[14]], m[s[15]])
232 + }
233 +
234 + for i in 0..<8 {
235 + h[i] ^= v[i] ^ v[i + 8]
236 + }
237 + }
238 +
239 + private static func g(_ v: inout [UInt64], _ a: Int, _ b: Int, _ c: Int, _ d: Int, _ x: UInt64, _ y: UInt64) {
240 + v[a] = v[a] &+ v[b] &+ x
241 + v[d] = rotr(v[d] ^ v[a], 32)
242 + v[c] = v[c] &+ v[d]
243 + v[b] = rotr(v[b] ^ v[c], 24)
244 + v[a] = v[a] &+ v[b] &+ y
245 + v[d] = rotr(v[d] ^ v[a], 16)
246 + v[c] = v[c] &+ v[d]
247 + v[b] = rotr(v[b] ^ v[c], 63)
248 + }
249 +
250 + private static func rotr(_ x: UInt64, _ n: UInt64) -> UInt64 {
251 + (x >> n) | (x << (64 - n))
252 + }
253 +}
254 +
255 +// MARK: - Argon2id (RFC 9106, version 0x13), single-lane (p = 1)
256 +
257 +enum Argon2id {
258 + private static let blockWords = 128 // 1024 bytes
259 + private static let syncPoints = 4
260 + private static let addressesInBlock = 128
261 +
262 + /// Argon2id hash; `parallelism` must be 1 (matches the server parameters).
263 + static func hash(
264 + password: [UInt8],
265 + salt: [UInt8],
266 + timeCost: Int,
267 + memoryKiB: Int,
268 + parallelism: Int,
269 + outputLength: Int
270 + ) -> [UInt8] {
271 + precondition(parallelism == 1, "single-lane implementation")
272 + precondition(timeCost >= 1 && memoryKiB >= 8)
273 +
274 + let mPrime = (memoryKiB / (4 * parallelism)) * 4 * parallelism
275 + let laneLength = mPrime / parallelism
276 + let segmentLength = laneLength / syncPoints
277 +
278 + // H0
279 + var seed = [UInt8]()
280 + seed.append(contentsOf: le32(parallelism))
281 + seed.append(contentsOf: le32(outputLength))
282 + seed.append(contentsOf: le32(memoryKiB))
283 + seed.append(contentsOf: le32(timeCost))
284 + seed.append(contentsOf: le32(0x13)) // version
285 + seed.append(contentsOf: le32(2)) // type Argon2id
286 + seed.append(contentsOf: le32(password.count))
287 + seed.append(contentsOf: password)
288 + seed.append(contentsOf: le32(salt.count))
289 + seed.append(contentsOf: salt)
290 + seed.append(contentsOf: le32(0)) // secret
291 + seed.append(contentsOf: le32(0)) // associated data
292 + let h0 = Blake2b.hash(seed, outLen: 64)
293 +
294 + var memory = [UInt64](repeating: 0, count: mPrime * blockWords)
295 +
296 + // First two blocks of the lane.
297 + storeBlock(hPrime(h0 + le32(0) + le32(0), outLen: 1024), into: &memory, index: 0)
298 + storeBlock(hPrime(h0 + le32(1) + le32(0), outLen: 1024), into: &memory, index: 1)
299 +
300 + for pass in 0..<timeCost {
301 + for slice in 0..<syncPoints {
302 + fillSegment(
303 + memory: &memory,
304 + pass: pass,
305 + slice: slice,
306 + timeCost: timeCost,
307 + mPrime: mPrime,
308 + laneLength: laneLength,
309 + segmentLength: segmentLength
310 + )
311 + }
312 + }
313 +
314 + let finalBlock = loadBlockBytes(memory, index: laneLength - 1)
315 + return hPrime(finalBlock, outLen: outputLength)
316 + }
317 +
318 + // H': variable-length hash (RFC 9106 §3.3).
319 + private static func hPrime(_ input: [UInt8], outLen: Int) -> [UInt8] {
320 + let prefixed = le32(outLen) + input
321 + if outLen <= 64 {
322 + return Blake2b.hash(prefixed, outLen: outLen)
323 + }
324 + let r = (outLen + 31) / 32 - 2
325 + var out = [UInt8]()
326 + out.reserveCapacity(outLen)
327 + var v = Blake2b.hash(prefixed, outLen: 64)
328 + out.append(contentsOf: v[0..<32])
329 + for _ in 1..<r {
330 + v = Blake2b.hash(v, outLen: 64)
331 + out.append(contentsOf: v[0..<32])
332 + }
333 + v = Blake2b.hash(v, outLen: outLen - 32 * r)
334 + out.append(contentsOf: v)
335 + return out
336 + }
337 +
338 + private static func fillSegment(
339 + memory: inout [UInt64],
340 + pass: Int,
341 + slice: Int,
342 + timeCost: Int,
343 + mPrime: Int,
344 + laneLength: Int,
345 + segmentLength: Int
346 + ) {
347 + // Argon2id: data-independent addressing for the first two slices of pass 0.
348 + let dataIndependent = pass == 0 && slice < 2
349 +
350 + var addressBlock = [UInt64](repeating: 0, count: blockWords)
351 + var inputBlock = [UInt64](repeating: 0, count: blockWords)
352 + let zeroBlock = [UInt64](repeating: 0, count: blockWords)
353 + if dataIndependent {
354 + inputBlock[0] = UInt64(pass)
355 + inputBlock[1] = 0 // lane
356 + inputBlock[2] = UInt64(slice)
357 + inputBlock[3] = UInt64(mPrime)
358 + inputBlock[4] = UInt64(timeCost)
359 + inputBlock[5] = 2 // Argon2id
360 + }
361 +
362 + var startingIndex = 0
363 + if pass == 0 && slice == 0 {
364 + startingIndex = 2
365 + if dataIndependent {
366 + nextAddresses(address: &addressBlock, input: &inputBlock, zero: zeroBlock)
367 + }
368 + }
369 +
370 + var currOffset = slice * segmentLength + startingIndex
371 + var prevOffset = currOffset == 0 ? laneLength - 1 : currOffset - 1
372 +
373 + for i in startingIndex..<segmentLength {
374 + if currOffset % laneLength == 0 {
375 + prevOffset = currOffset + laneLength - 1
376 + }
377 +
378 + let pseudoRand: UInt64
379 + if dataIndependent {
380 + if i % addressesInBlock == 0 {
381 + nextAddresses(address: &addressBlock, input: &inputBlock, zero: zeroBlock)
382 + }
383 + pseudoRand = addressBlock[i % addressesInBlock]
384 + } else {
385 + pseudoRand = memory[prevOffset * blockWords]
386 + }
387 + // Single lane: ref lane is always our lane.
388 + let j1 = pseudoRand & 0xFFFF_FFFF
389 + let refIndex = indexAlpha(
390 + pass: pass,
391 + slice: slice,
392 + index: i,
393 + j1: j1,
394 + laneLength: laneLength,
395 + segmentLength: segmentLength
396 + )
397 +
398 + let prevBlock = readBlock(memory, index: prevOffset)
399 + let refBlock = readBlock(memory, index: refIndex)
400 + var current = readBlock(memory, index: currOffset)
401 + fillBlock(prev: prevBlock, ref: refBlock, next: &current, withXor: pass > 0)
402 + writeBlock(current, into: &memory, index: currOffset)
403 +
404 + currOffset += 1
405 + prevOffset = currOffset - 1
406 + }
407 + }
408 +
409 + private static func indexAlpha(
410 + pass: Int,
411 + slice: Int,
412 + index: Int,
413 + j1: UInt64,
414 + laneLength: Int,
415 + segmentLength: Int
416 + ) -> Int {
417 + let refAreaSize: UInt64
418 + if pass == 0 {
419 + if slice == 0 {
420 + refAreaSize = UInt64(index - 1)
421 + } else {
422 + refAreaSize = UInt64(slice * segmentLength + index - 1)
423 + }
424 + } else {
425 + refAreaSize = UInt64(laneLength - segmentLength + index - 1)
426 + }
427 +
428 + var relativePosition = j1
429 + relativePosition = (relativePosition &* relativePosition) >> 32
430 + relativePosition = refAreaSize - 1 - ((refAreaSize &* relativePosition) >> 32)
431 +
432 + let startPosition: Int
433 + if pass == 0 || slice == syncPoints - 1 {
434 + startPosition = 0
435 + } else {
436 + startPosition = (slice + 1) * segmentLength
437 + }
438 + return (startPosition + Int(relativePosition)) % laneLength
439 + }
440 +
441 + private static func nextAddresses(address: inout [UInt64], input: inout [UInt64], zero: [UInt64]) {
442 + input[6] &+= 1
443 + fillBlock(prev: zero, ref: input, next: &address, withXor: false)
444 + let copy = address
445 + fillBlock(prev: zero, ref: copy, next: &address, withXor: false)
446 + }
447 +
448 + /// Argon2 compression G: next = P(prev ^ ref) ^ (prev ^ ref) [^ next when withXor].
449 + private static func fillBlock(prev: [UInt64], ref: [UInt64], next: inout [UInt64], withXor: Bool) {
450 + var blockR = [UInt64](repeating: 0, count: blockWords)
451 + for i in 0..<blockWords { blockR[i] = prev[i] ^ ref[i] }
452 + var blockTmp = blockR
453 + if withXor {
454 + for i in 0..<blockWords { blockTmp[i] ^= next[i] }
455 + }
456 +
457 + // Apply P row-wise: 8 rows of 16 consecutive u64.
458 + for i in 0..<8 {
459 + let o = 16 * i
460 + permutation(
461 + &blockR,
462 + o, o + 1, o + 2, o + 3, o + 4, o + 5, o + 6, o + 7,
463 + o + 8, o + 9, o + 10, o + 11, o + 12, o + 13, o + 14, o + 15
464 + )
465 + }
466 + // Apply P column-wise: 8 columns of 2-u64 pairs.
467 + for i in 0..<8 {
468 + let o = 2 * i
469 + permutation(
470 + &blockR,
471 + o, o + 1, o + 16, o + 17, o + 32, o + 33, o + 48, o + 49,
472 + o + 64, o + 65, o + 80, o + 81, o + 96, o + 97, o + 112, o + 113
473 + )
474 + }
475 +
476 + for i in 0..<blockWords { next[i] = blockTmp[i] ^ blockR[i] }
477 + }
478 +
479 + private static func permutation(
480 + _ v: inout [UInt64],
481 + _ v0: Int, _ v1: Int, _ v2: Int, _ v3: Int,
482 + _ v4: Int, _ v5: Int, _ v6: Int, _ v7: Int,
483 + _ v8: Int, _ v9: Int, _ v10: Int, _ v11: Int,
484 + _ v12: Int, _ v13: Int, _ v14: Int, _ v15: Int
485 + ) {
486 + blamkaG(&v, v0, v4, v8, v12)
487 + blamkaG(&v, v1, v5, v9, v13)
488 + blamkaG(&v, v2, v6, v10, v14)
489 + blamkaG(&v, v3, v7, v11, v15)
490 + blamkaG(&v, v0, v5, v10, v15)
491 + blamkaG(&v, v1, v6, v11, v12)
492 + blamkaG(&v, v2, v7, v8, v13)
493 + blamkaG(&v, v3, v4, v9, v14)
494 + }
495 +
496 + @inline(__always)
497 + private static func blamkaG(_ v: inout [UInt64], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
498 + v[a] = fBlaMka(v[a], v[b]); v[d] = rotr(v[d] ^ v[a], 32)
499 + v[c] = fBlaMka(v[c], v[d]); v[b] = rotr(v[b] ^ v[c], 24)
500 + v[a] = fBlaMka(v[a], v[b]); v[d] = rotr(v[d] ^ v[a], 16)
501 + v[c] = fBlaMka(v[c], v[d]); v[b] = rotr(v[b] ^ v[c], 63)
502 + }
503 +
504 + @inline(__always)
505 + private static func fBlaMka(_ x: UInt64, _ y: UInt64) -> UInt64 {
506 + let m = (x & 0xFFFF_FFFF) &* (y & 0xFFFF_FFFF)
507 + return x &+ y &+ (m &<< 1)
508 + }
509 +
510 + @inline(__always)
511 + private static func rotr(_ x: UInt64, _ n: UInt64) -> UInt64 {
512 + (x >> n) | (x << (64 - n))
513 + }
514 +
515 + // ---- block/bytes helpers ----
516 +
517 + private static func readBlock(_ memory: [UInt64], index: Int) -> [UInt64] {
518 + Array(memory[(index * blockWords)..<((index + 1) * blockWords)])
519 + }
520 +
521 + private static func writeBlock(_ block: [UInt64], into memory: inout [UInt64], index: Int) {
522 + memory.replaceSubrange((index * blockWords)..<((index + 1) * blockWords), with: block)
523 + }
524 +
525 + private static func storeBlock(_ bytes: [UInt8], into memory: inout [UInt64], index: Int) {
526 + precondition(bytes.count == 1024)
527 + for i in 0..<blockWords {
528 + let o = 8 * i
529 + memory[index * blockWords + i] = UInt64(bytes[o])
530 + | (UInt64(bytes[o + 1]) << 8)
531 + | (UInt64(bytes[o + 2]) << 16)
532 + | (UInt64(bytes[o + 3]) << 24)
533 + | (UInt64(bytes[o + 4]) << 32)
534 + | (UInt64(bytes[o + 5]) << 40)
535 + | (UInt64(bytes[o + 6]) << 48)
536 + | (UInt64(bytes[o + 7]) << 56)
537 + }
538 + }
539 +
540 + private static func loadBlockBytes(_ memory: [UInt64], index: Int) -> [UInt8] {
541 + var out = [UInt8]()
542 + out.reserveCapacity(1024)
543 + for i in 0..<blockWords {
544 + var word = memory[index * blockWords + i].littleEndian
545 + withUnsafeBytes(of: &word) { out.append(contentsOf: $0) }
546 + }
547 + return out
548 + }
549 +
550 + private static func le32(_ value: Int) -> [UInt8] {
551 + let v = UInt32(truncatingIfNeeded: value)
552 + return [
553 + UInt8(v & 0xFF),
554 + UInt8((v >> 8) & 0xFF),
555 + UInt8((v >> 16) & 0xFF),
556 + UInt8((v >> 24) & 0xFF),
557 + ]
558 + }
559 +}
A ios/Card2vcf/Sync/CalendarBindingsStore.swift
+55 -0
@@ -0,0 +1,55 @@
1 +import Foundation
2 +
3 +/// Binding between a local device calendar and a Projectiaon entity (RDV or bookable resource).
4 +/// JSON keys match the Android store (`kind`/`serverResourceId`/`displayName`/`androidCalendarId`);
5 +/// `androidCalendarId` keeps its name for wire compatibility — on iOS it holds the
6 +/// `EventKitIdMap` numeric id of the bound calendar.
7 +struct CalendarBinding: Codable, Equatable {
8 + var kind: String // "rdv" | "salle" | "materiel" | "vehicule"
9 + var serverResourceId: String? = nil
10 + var displayName: String
11 + var androidCalendarId: Int64? = nil
12 +}
13 +
14 +/// Persists the user-bound `CalendarBinding`s as JSON (Android: SharedPreferences
15 +/// `card2vcf_calendar_bindings`; iOS: UserDefaults, same key names).
16 +final class CalendarBindingsStore {
17 + static let PREFS_NAME = "card2vcf_calendar_bindings"
18 + static let KEY_BINDINGS = "bindings"
19 +
20 + private let defaults: UserDefaults
21 + private var storageKey: String { "\(Self.PREFS_NAME).\(Self.KEY_BINDINGS)" }
22 +
23 + init(defaults: UserDefaults = .standard) {
24 + self.defaults = defaults
25 + }
26 +
27 + func list() -> [CalendarBinding] {
28 + guard let raw = defaults.string(forKey: storageKey) else { return [] }
29 + return (try? JSONDecoder().decode([CalendarBinding].self, from: Data(raw.utf8))) ?? []
30 + }
31 +
32 + /// Replaces the existing binding with the same `kind`/`serverResourceId`, otherwise appends it.
33 + func upsert(_ binding: CalendarBinding) {
34 + let updated = list().filter { !matches($0, kind: binding.kind, serverResourceId: binding.serverResourceId) } + [binding]
35 + save(updated)
36 + }
37 +
38 + func remove(kind: String, serverResourceId: String?) {
39 + save(list().filter { !matches($0, kind: kind, serverResourceId: serverResourceId) })
40 + }
41 +
42 + func clear() {
43 + defaults.removeObject(forKey: storageKey)
44 + }
45 +
46 + private func save(_ bindings: [CalendarBinding]) {
47 + guard let data = try? JSONEncoder().encode(bindings),
48 + let json = String(data: data, encoding: .utf8) else { return }
49 + defaults.set(json, forKey: storageKey)
50 + }
51 +
52 + private func matches(_ binding: CalendarBinding, kind: String, serverResourceId: String?) -> Bool {
53 + binding.kind == kind && binding.serverResourceId == serverResourceId
54 + }
55 +}
A ios/Card2vcf/Sync/CalendarBridge.swift
+256 -0
@@ -0,0 +1,256 @@
1 +import EventKit
2 +import Foundation
3 +
4 +/// Encodes/decodes the remote `serverId` of an event inside its description/notes.
5 +///
6 +/// Same stable line-prefix format as Android (`CalendarContract.Events.DESCRIPTION`);
7 +/// on iOS the payload lives in `EKEvent.notes`. The user-visible description
8 +/// (`CalendarEventSnapshot.descriptionBody`) immediately follows the prefix line.
9 +enum CalendarServerIdCodec {
10 + private static let PREFIX = "card2vcf:serverId="
11 +
12 + static func encode(serverId: String?, descriptionBody: String) -> String {
13 + guard let serverId else { return descriptionBody }
14 + return "\(PREFIX)\(serverId)\n\(descriptionBody)"
15 + }
16 +
17 + /// Returns `(serverId, descriptionBody)`; `serverId` is `nil` when the prefix is absent.
18 + static func decode(_ description: String?) -> (String?, String) {
19 + guard let description else { return (nil, "") }
20 + guard description.hasPrefix(PREFIX) else { return (nil, description) }
21 +
22 + let rest = String(description.dropFirst(PREFIX.count))
23 + guard let newlineIndex = rest.firstIndex(of: "\n") else {
24 + return (rest, "")
25 + }
26 + return (
27 + String(rest[rest.startIndex..<newlineIndex]),
28 + String(rest[rest.index(after: newlineIndex)...])
29 + )
30 + }
31 +}
32 +
33 +/// Title prefix distinguishing unavailabilities (read-only mirror) from reservations/RDV.
34 +enum CalendarEventTitles {
35 + private static let INDISPO_PREFIX = "[Indispo] "
36 +
37 + static func indispoTitle(_ title: String) -> String { "\(INDISPO_PREFIX)\(title)" }
38 +
39 + static func isIndispoTitle(_ title: String) -> Bool { title.hasPrefix(INDISPO_PREFIX) }
40 +
41 + static func stripIndispoPrefix(_ title: String) -> String {
42 + title.hasPrefix(INDISPO_PREFIX) ? String(title.dropFirst(INDISPO_PREFIX.count)) : title
43 + }
44 +}
45 +
46 +/// Calendar-bridge contract used by `SyncEngine`/`AgendaSyncCoordinator`
47 +/// (Kotlin `CalendarBridgeApi`); `EventKitCalendarBridge` implements it, fake in tests.
48 +protocol CalendarBridge {
49 + @discardableResult
50 + func ensureLocalCalendar(displayName: String) throws -> Int64
51 +
52 + func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot]
53 +
54 + @discardableResult
55 + func upsertEvent(calendarId: Int64, snap: CalendarEventSnapshot) throws -> Int64
56 +
57 + func deleteEvent(eventId: Int64) throws
58 +}
59 +
60 +enum CalendarBridgeError: Error {
61 + case accessDenied
62 + case noLocalSource
63 + case calendarCreationFailed(String)
64 + case eventCreationFailed(String)
65 +}
66 +
67 +/// EventKit bridge over **local** calendars (mirror of Android's
68 +/// `ACCOUNT_TYPE_LOCAL` calendars: works without any cloud account).
69 +///
70 +/// Deviations from the Android `ContentResolver` bridge, by necessity:
71 +/// - EventKit identifies calendars/events with `String` identifiers; the Room
72 +/// entities store `Int64` ids, so a persistent `Int64 ⇄ identifier` map
73 +/// (UserDefaults) assigns stable numeric ids.
74 +/// - EventKit event queries require a bounded date window (max 4 years per
75 +/// predicate); `listEvents` uses [now − 1 year, now + 3 years].
76 +final class EventKitCalendarBridge: CalendarBridge {
77 + private let store: EKEventStore
78 + private let ids: EventKitIdMap
79 +
80 + init(store: EKEventStore = EKEventStore(), defaults: UserDefaults = .standard) {
81 + self.store = store
82 + self.ids = EventKitIdMap(defaults: defaults)
83 + }
84 +
85 + /// Requests calendar access: iOS 17 full-access API when available,
86 + /// legacy `requestAccess(to:)` otherwise.
87 + func requestFullAccess() async throws -> Bool {
88 + if #available(iOS 17.0, *) {
89 + return try await store.requestFullAccessToEvents()
90 + }
91 + return try await withCheckedThrowingContinuation { continuation in
92 + store.requestAccess(to: .event) { granted, error in
93 + if let error {
94 + continuation.resume(throwing: error)
95 + } else {
96 + continuation.resume(returning: granted)
97 + }
98 + }
99 + }
100 + }
101 +
102 + /// Creates the local calendar `displayName` if missing, and returns its id.
103 + @discardableResult
104 + func ensureLocalCalendar(displayName: String) throws -> Int64 {
105 + if let existing = findCalendar(displayName: displayName) {
106 + return ids.id(for: existing.calendarIdentifier)
107 + }
108 +
109 + guard let source = localSource() else {
110 + throw CalendarBridgeError.noLocalSource
111 + }
112 + let calendar = EKCalendar(for: .event, eventStore: store)
113 + calendar.title = displayName
114 + calendar.source = source
115 + do {
116 + try store.saveCalendar(calendar, commit: true)
117 + } catch {
118 + throw CalendarBridgeError.calendarCreationFailed(displayName)
119 + }
120 + return ids.id(for: calendar.calendarIdentifier)
121 + }
122 +
123 + func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] {
124 + guard let calendar = calendar(for: calendarId) else { return [] }
125 + let start = Date(timeIntervalSinceNow: -365 * 24 * 3600)
126 + let end = Date(timeIntervalSinceNow: 3 * 365 * 24 * 3600)
127 + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: [calendar])
128 + return store.events(matching: predicate).compactMap { event in
129 + guard let identifier = event.eventIdentifier else { return nil }
130 + let (serverId, body) = CalendarServerIdCodec.decode(event.notes)
131 + return CalendarEventSnapshot(
132 + title: event.title ?? "",
133 + debutMs: Int64((event.startDate?.timeIntervalSince1970 ?? 0) * 1000),
134 + finMs: Int64((event.endDate?.timeIntervalSince1970 ?? 0) * 1000),
135 + eventId: ids.id(for: identifier),
136 + serverId: serverId,
137 + descriptionBody: body
138 + )
139 + }
140 + }
141 +
142 + /// Creates the event (when `snap.eventId == nil`) or updates it, and returns its id.
143 + @discardableResult
144 + func upsertEvent(calendarId: Int64, snap: CalendarEventSnapshot) throws -> Int64 {
145 + if let existingEventId = snap.eventId {
146 + // Mirror Android: update-by-id is a no-op when the row vanished; keep the id.
147 + if let identifier = ids.identifier(for: existingEventId),
148 + let event = store.event(withIdentifier: identifier) {
149 + apply(snap, to: event, calendarId: calendarId)
150 + try store.save(event, span: .thisEvent, commit: true)
151 + }
152 + return existingEventId
153 + }
154 +
155 + let event = EKEvent(eventStore: store)
156 + apply(snap, to: event, calendarId: calendarId)
157 + do {
158 + try store.save(event, span: .thisEvent, commit: true)
159 + } catch {
160 + throw CalendarBridgeError.eventCreationFailed(snap.title)
161 + }
162 + guard let identifier = event.eventIdentifier else {
163 + throw CalendarBridgeError.eventCreationFailed(snap.title)
164 + }
165 + return ids.id(for: identifier)
166 + }
167 +
168 + func deleteEvent(eventId: Int64) throws {
169 + guard let identifier = ids.identifier(for: eventId),
170 + let event = store.event(withIdentifier: identifier) else {
171 + return
172 + }
173 + try store.remove(event, span: .thisEvent, commit: true)
174 + ids.remove(id: eventId)
175 + }
176 +
177 + // MARK: - Helpers
178 +
179 + private func apply(_ snap: CalendarEventSnapshot, to event: EKEvent, calendarId: Int64) {
180 + if let calendar = calendar(for: calendarId) {
181 + event.calendar = calendar
182 + }
183 + event.title = snap.title
184 + event.notes = CalendarServerIdCodec.encode(serverId: snap.serverId, descriptionBody: snap.descriptionBody)
185 + event.startDate = Date(timeIntervalSince1970: Double(snap.debutMs) / 1000.0)
186 + event.endDate = Date(timeIntervalSince1970: Double(snap.finMs) / 1000.0)
187 + event.timeZone = TimeZone.current
188 + }
189 +
190 + private func findCalendar(displayName: String) -> EKCalendar? {
191 + store.calendars(for: .event).first {
192 + $0.title == displayName && $0.source?.sourceType == .local
193 + }
194 + }
195 +
196 + private func calendar(for id: Int64) -> EKCalendar? {
197 + guard let identifier = ids.identifier(for: id) else { return nil }
198 + return store.calendar(withIdentifier: identifier)
199 + }
200 +
201 + private func localSource() -> EKSource? {
202 + store.sources.first { $0.sourceType == .local }
203 + ?? store.defaultCalendarForNewEvents?.source
204 + }
205 +}
206 +
207 +/// Persistent `Int64 ⇄ EventKit identifier` map (calendars and events share the
208 +/// same namespace; EventKit identifiers are unique strings).
209 +final class EventKitIdMap {
210 + private static let mapKey = "fr.ebii.card2vcf.sync.eventkit_ids"
211 + private static let nextIdKey = "fr.ebii.card2vcf.sync.eventkit_next_id"
212 +
213 + private let defaults: UserDefaults
214 + private var idToIdentifier: [String: String]
215 + private var identifierToId: [String: Int64]
216 + private var nextId: Int64
217 +
218 + init(defaults: UserDefaults = .standard) {
219 + self.defaults = defaults
220 + let stored = defaults.dictionary(forKey: Self.mapKey) as? [String: String] ?? [:]
221 + self.idToIdentifier = stored
222 + var reverse: [String: Int64] = [:]
223 + for (idString, identifier) in stored {
224 + if let id = Int64(idString) { reverse[identifier] = id }
225 + }
226 + self.identifierToId = reverse
227 + let storedNext = defaults.object(forKey: Self.nextIdKey) as? NSNumber
228 + self.nextId = storedNext?.int64Value ?? 1
229 + }
230 +
231 + /// Stable id for the identifier, assigning a new one if needed.
232 + func id(for identifier: String) -> Int64 {
233 + if let existing = identifierToId[identifier] { return existing }
234 + let id = nextId
235 + nextId += 1
236 + identifierToId[identifier] = id
237 + idToIdentifier[String(id)] = identifier
238 + persist()
239 + return id
240 + }
241 +
242 + func identifier(for id: Int64) -> String? {
243 + idToIdentifier[String(id)]
244 + }
245 +
246 + func remove(id: Int64) {
247 + guard let identifier = idToIdentifier.removeValue(forKey: String(id)) else { return }
248 + identifierToId.removeValue(forKey: identifier)
249 + persist()
250 + }
251 +
252 + private func persist() {
253 + defaults.set(idToIdentifier, forKey: Self.mapKey)
254 + defaults.set(NSNumber(value: nextId), forKey: Self.nextIdKey)
255 + }
256 +}
A ios/Card2vcf/Sync/CalendarEventSnapshot.swift
+12 -0
@@ -0,0 +1,12 @@
1 +import Foundation
2 +
3 +/// Lightweight mirror of a calendar event, independent from EventKit
4 +/// (Android: mirror of `CalendarContract.Events` rows).
5 +struct CalendarEventSnapshot: Equatable {
6 + var title: String
7 + var debutMs: Int64
8 + var finMs: Int64
9 + var eventId: Int64? = nil
10 + var serverId: String? = nil
11 + var descriptionBody: String = ""
12 +}
A ios/Card2vcf/Sync/ContactSyncMapper.swift
+46 -0
@@ -0,0 +1,46 @@
1 +import Foundation
2 +
3 +/// Maps a local contact entity to the server `ContactInput` JSON
4 +/// (Kotlin `ContactSyncMapper`; conforms to the Data-layer port).
5 +struct ContactSyncMapper: ContactSyncPayloadMapping {
6 + func toCreatePayload(_ entity: CrmContactEntity) -> String {
7 + SyncJson.encodeToString(Self.request(from: entity))
8 + }
9 +
10 + func toUpdatePayload(_ entity: CrmContactEntity) -> String {
11 + toCreatePayload(entity)
12 + }
13 +
14 + private static func request(from entity: CrmContactEntity) -> CreateContactRequest {
15 + let (prenom, nom) = resolveNames(entity)
16 + return CreateContactRequest(
17 + prenom: prenom,
18 + nom: nom,
19 + entrepriseId: nonBlank(entity.entrepriseServerId),
20 + fonction: entity.jobTitle ?? "",
21 + emails: entity.emails.compactMap(nonBlank).map { ContactValeurDto(valeur: $0.trimmingCharacters(in: .whitespacesAndNewlines)) },
22 + telephones: entity.phones.compactMap(nonBlank).map { ContactValeurDto(valeur: $0.trimmingCharacters(in: .whitespacesAndNewlines)) },
23 + notes: entity.notes ?? "",
24 + statut: nonBlank(entity.statut) ?? "prospect",
25 + etape: nonBlank(entity.etape) ?? "nouveau",
26 + tags: entity.tags
27 + )
28 + }
29 +
30 + private static func nonBlank(_ value: String?) -> String? {
31 + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
32 + return value
33 + }
34 +
35 + private static func resolveNames(_ entity: CrmContactEntity) -> (String, String) {
36 + let fn = entity.firstName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
37 + let ln = entity.lastName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
38 + if !fn.isEmpty || !ln.isEmpty { return (fn, ln) }
39 + let full = entity.fullName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
40 + if full.isEmpty { return ("", "") }
41 + let parts = full.split(maxSplits: 1, whereSeparator: { $0.isWhitespace })
42 + if parts.count == 1 { return ("", String(parts[0])) }
43 + let rest = String(parts[1].drop(while: { $0.isWhitespace }))
44 + return (String(parts[0]), rest)
45 + }
46 +}
A ios/Card2vcf/Sync/CrmDatabase.swift
+31 -0
@@ -0,0 +1,31 @@
1 +import Foundation
2 +
3 +/// Aggregate over the Data-layer DAO ports (Kotlin Room `CrmDatabase`),
4 +/// consumed by `SyncEngine`/`AgendaSyncCoordinator`. The SQLite implementation
5 +/// and in-memory test double both conform to this.
6 +protocol CrmDatabase {
7 + var crmContactDao: CrmContactDao { get }
8 + var entrepriseDao: EntrepriseDao { get }
9 + var projetDao: ProjetDao { get }
10 + var tacheDao: TacheDao { get }
11 + var interactionDao: InteractionDao { get }
12 + var workflowDao: WorkflowDao { get }
13 + var rdvDao: RdvDao { get }
14 + var reservationDao: ReservationDao { get }
15 + var indisponibiliteDao: IndisponibiliteDao { get }
16 + var syncMetaDao: SyncMetaDao { get }
17 + var syncOpDao: SyncOpDao { get }
18 +}
19 +
20 +/// The SQLite facade exposes exactly these DAO properties.
21 +extension Card2vcfDatabase: CrmDatabase {}
22 +
23 +/// Slice of Android `ContactImageStore` used by `SyncEngine` to persist downloaded
24 +/// contact images; returns the stored file path. The Data-layer `ContactImageStore`
25 +/// class already provides this method.
26 +protocol ContactImageSaving {
27 + @discardableResult
28 + func saveBytes(contactId: Int64, name: String, bytes: Data) throws -> String
29 +}
30 +
31 +extension ContactImageStore: ContactImageSaving {}
A ios/Card2vcf/Sync/LwwMerger.swift
+28 -0
@@ -0,0 +1,28 @@
1 +import Foundation
2 +
3 +enum LwwMerger {
4 + /// Returns remote if remoteTs >= localTs (remote wins ties), else local.
5 + static func pickLww<T>(local: T, localTs: Int64, remote: T, remoteTs: Int64) -> T {
6 + remoteTs >= localTs ? remote : local
7 + }
8 +
9 + struct TaskPlacement: Equatable {
10 + var colonneId: String
11 + var ordre: Int
12 + var updatedAt: Int64
13 + }
14 +
15 + static func pickTaskMove(local: TaskPlacement, remote: TaskPlacement) -> TaskPlacement {
16 + pickLww(local: local, localTs: local.updatedAt, remote: remote, remoteTs: remote.updatedAt)
17 + }
18 +
19 + /// Append-only: insert interaction only if serverId is not already known locally.
20 + static func shouldInsertInteraction(existingServerIds: Set<String>, remoteServerId: String) -> Bool {
21 + !existingServerIds.contains(remoteServerId)
22 + }
23 +
24 + /// Caller deletes local rows by serverId; returns remaining ids after tombstone application.
25 + static func applyTombstoneIds(localServerIds: Set<String>, tombstoneIds: Set<String>) -> Set<String> {
26 + localServerIds.subtracting(tombstoneIds)
27 + }
28 +}
A ios/Card2vcf/Sync/ServerUrlPolicy.swift
+63 -0
@@ -0,0 +1,63 @@
1 +import Foundation
2 +
3 +/// Server URL policy: HTTPS required by default.
4 +/// Cleartext HTTP is only accepted when the user checked
5 +/// « Autoriser HTTP (clair) » (dev / internal network / demo).
6 +enum ServerUrlPolicy {
7 + static let ERROR_HTTPS_REQUIRED =
8 + "L'URL doit commencer par https:// (cochez « Autoriser HTTP (clair) » pour le développement)."
9 + static let ERROR_INVALID_URL =
10 + "URL invalide : utilisez https://… (ou http://… si HTTP clair autorisé)."
11 + static let ERROR_LAN_HTTPS_NO_PORT =
12 + "URL HTTPS sans port sur une IP locale : Projectiaon écoute en HTTP sur le port 3000. " +
13 + "Utilisez http://192.168.x.x:3000 et cochez « Autoriser HTTP (clair) »."
14 + static let ERROR_LAN_HTTP_NO_PORT =
15 + "URL sans port : Projectiaon écoute sur le port 3000 (ex. http://192.168.x.x:3000)."
16 +
17 + static func isHttps(_ url: String) -> Bool {
18 + url.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("https://")
19 + }
20 +
21 + static func isHttp(_ url: String) -> Bool {
22 + url.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("http://")
23 + }
24 +
25 + /// API root: no trailing slash nor browser-test path (`/version`).
26 + static func normalizeBaseUrl(_ url: String) -> String {
27 + var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
28 + while trimmed.hasSuffix("/") { trimmed.removeLast() }
29 + if trimmed.hasSuffix("/version") { trimmed.removeLast("/version".count) }
30 + return trimmed
31 + }
32 +
33 + /// `nil` when the URL is acceptable, otherwise the error message to display.
34 + static func validate(_ url: String, allowCleartext: Bool) -> String? {
35 + let trimmed = normalizeBaseUrl(url)
36 + if trimmed.isEmpty { return ERROR_INVALID_URL }
37 + if isHttps(trimmed) {
38 + return lanHostWithoutPortError(trimmed)
39 + }
40 + if isHttp(trimmed) {
41 + if !allowCleartext { return ERROR_HTTPS_REQUIRED }
42 + return lanHostWithoutPortError(trimmed)
43 + }
44 + return ERROR_INVALID_URL
45 + }
46 +
47 + private static func lanHostWithoutPortError(_ url: String) -> String? {
48 + guard let components = URLComponents(string: url) else { return nil }
49 + if components.port != nil { return nil }
50 + guard let host = components.host else { return nil }
51 + if !isPrivateLanHost(host) { return nil }
52 + if isHttps(url) { return ERROR_LAN_HTTPS_NO_PORT }
53 + if isHttp(url) { return ERROR_LAN_HTTP_NO_PORT }
54 + return nil
55 + }
56 +
57 + private static func isPrivateLanHost(_ host: String) -> Bool {
58 + if host.caseInsensitiveCompare("localhost") == .orderedSame || host == "127.0.0.1" { return true }
59 + if host.hasPrefix("192.168.") { return true }
60 + if host.hasPrefix("10.") { return true }
61 + return host.range(of: #"^172\.(1[6-9]|2\d|3[01])\..+$"#, options: .regularExpression) != nil
62 + }
63 +}
A ios/Card2vcf/Sync/SyncCredentialsStore.swift
+130 -0
@@ -0,0 +1,130 @@
1 +import Foundation
2 +import Security
3 +
4 +/// Backing storage abstraction (Android `EncryptedSharedPreferences`); Keychain in
5 +/// production, in-memory in tests. `nil` value removes the entry.
6 +protocol SyncCredentialsStorage {
7 + func get(_ key: String) -> String?
8 + func set(_ key: String, _ value: String?)
9 +}
10 +
11 +/// Keychain-backed storage: one generic-password item per key under a dedicated service.
12 +final class KeychainCredentialsStorage: SyncCredentialsStorage {
13 + static let service = "fr.ebii.card2vcf.sync"
14 +
15 + private let service: String
16 +
17 + init(service: String = KeychainCredentialsStorage.service) {
18 + self.service = service
19 + }
20 +
21 + func get(_ key: String) -> String? {
22 + var query = baseQuery(key)
23 + query[kSecReturnData as String] = true
24 + query[kSecMatchLimit as String] = kSecMatchLimitOne
25 + var item: CFTypeRef?
26 + let status = SecItemCopyMatching(query as CFDictionary, &item)
27 + guard status == errSecSuccess, let data = item as? Data else { return nil }
28 + return String(data: data, encoding: .utf8)
29 + }
30 +
31 + func set(_ key: String, _ value: String?) {
32 + let query = baseQuery(key)
33 + guard let value else {
34 + SecItemDelete(query as CFDictionary)
35 + return
36 + }
37 + let data = Data(value.utf8)
38 + let update: [String: Any] = [kSecValueData as String: data]
39 + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
40 + if status == errSecItemNotFound {
41 + var add = query
42 + add[kSecValueData as String] = data
43 + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
44 + SecItemAdd(add as CFDictionary, nil)
45 + }
46 + }
47 +
48 + private func baseQuery(_ key: String) -> [String: Any] {
49 + [
50 + kSecClass as String: kSecClassGenericPassword,
51 + kSecAttrService as String: service,
52 + kSecAttrAccount as String: key,
53 + ]
54 + }
55 +}
56 +
57 +/// In-memory storage for tests / previews.
58 +final class InMemoryCredentialsStorage: SyncCredentialsStorage {
59 + private var values: [String: String] = [:]
60 +
61 + func get(_ key: String) -> String? { values[key] }
62 +
63 + func set(_ key: String, _ value: String?) {
64 + if let value {
65 + values[key] = value
66 + } else {
67 + values.removeValue(forKey: key)
68 + }
69 + }
70 +}
71 +
72 +/// Server URL + login + API key store (Kotlin `SyncCredentialsStore` over
73 +/// EncryptedSharedPreferences). The password itself is never persisted.
74 +final class SyncCredentialsStore {
75 + static let KEY_BASE_URL = "base_url"
76 + static let KEY_API_KEY = "api_key"
77 + static let KEY_USER_NAME = "user_name"
78 + static let KEY_ALLOW_CLEARTEXT = "allow_cleartext_http"
79 +
80 + private let storage: SyncCredentialsStorage
81 +
82 + init(storage: SyncCredentialsStorage = KeychainCredentialsStorage()) {
83 + self.storage = storage
84 + }
85 +
86 + var baseUrl: String? {
87 + get { nonBlank(storage.get(Self.KEY_BASE_URL)) }
88 + set { storage.set(Self.KEY_BASE_URL, nonBlank(newValue)) }
89 + }
90 +
91 + var apiKey: String? {
92 + get { nonBlank(storage.get(Self.KEY_API_KEY)) }
93 + set { storage.set(Self.KEY_API_KEY, nonBlank(newValue)) }
94 + }
95 +
96 + var userName: String? {
97 + get { nonBlank(storage.get(Self.KEY_USER_NAME)) }
98 + set { storage.set(Self.KEY_USER_NAME, nonBlank(newValue)) }
99 + }
100 +
101 + /// Allows `http://` URLs (internal network / demo / dev).
102 + /// Kept after `clear()`: not a secret, just a local preference.
103 + /// Default: `false` (HTTPS required).
104 + var allowCleartextHttp: Bool {
105 + get { storage.get(Self.KEY_ALLOW_CLEARTEXT) == "true" }
106 + set { storage.set(Self.KEY_ALLOW_CLEARTEXT, newValue ? "true" : "false") }
107 + }
108 +
109 + func save(baseUrl: String, apiKey: String, userName: String) {
110 + storage.set(Self.KEY_BASE_URL, baseUrl)
111 + storage.set(Self.KEY_API_KEY, apiKey)
112 + storage.set(Self.KEY_USER_NAME, userName)
113 + }
114 +
115 + func clear() {
116 + // Do not clear `allowCleartextHttp`: convenient in demo/internal setups between logins.
117 + storage.set(Self.KEY_BASE_URL, nil)
118 + storage.set(Self.KEY_API_KEY, nil)
119 + storage.set(Self.KEY_USER_NAME, nil)
120 + }
121 +
122 + func isConfigured() -> Bool {
123 + baseUrl != nil && apiKey != nil && userName != nil
124 + }
125 +
126 + private func nonBlank(_ value: String?) -> String? {
127 + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
128 + return value
129 + }
130 +}
A ios/Card2vcf/Sync/SyncEngine.swift
+699 -0
@@ -0,0 +1,699 @@
1 +import Foundation
2 +
3 +struct SyncStatusResult {
4 + var pendingRemoteChanges: Int
5 + var response: SyncStatusResponse? = nil
6 + var error: String? = nil
7 +}
8 +
9 +struct SyncResult {
10 + var success: Bool
11 + var serverTime: String? = nil
12 + var error: String? = nil
13 + var conflicts: [AgendaConflict] = []
14 +}
15 +
16 +/// Pushes the `SyncOpEntity` queue then pulls `/api/sync/pull`; LWW / append / tombstones.
17 +/// The calendar↔store bridge (RDV + reservations/unavailabilities bound to device calendars)
18 +/// is delegated to `AgendaSyncCoordinator`; this engine orchestrates the step ordering and
19 +/// the push (including the reservation 409 special case).
20 +final class SyncEngine {
21 + private static let watermarkKey = "watermark"
22 + private static let defaultWatermark = "1970-01-01T00:00:00Z"
23 + private static let entityContactMedia = "contact_media"
24 +
25 + private let api: AilianceApi
26 + private let db: CrmDatabase
27 + private let imageStore: ContactImageSaving?
28 + private let agenda: AgendaSyncCoordinator
29 +
30 + init(api: AilianceApi, db: CrmDatabase, imageStore: ContactImageSaving? = nil) {
31 + self.api = api
32 + self.db = db
33 + self.imageStore = imageStore
34 + self.agenda = AgendaSyncCoordinator(db: db)
35 + }
36 +
37 + func checkStatus(ressourcesQuery: String? = nil) async throws -> SyncStatusResult {
38 + switch await api.syncStatus(sinceIso: try await watermark(), ressourcesQuery: ressourcesQuery) {
39 + case .ok(let response):
40 + return SyncStatusResult(pendingRemoteChanges: response.total, response: response)
41 + case .err(_, let message):
42 + return SyncStatusResult(pendingRemoteChanges: 0, error: message)
43 + }
44 + }
45 +
46 + /// Empty `bindings`/nil `bridge`: unchanged CRM v1 path (no calendar bridge, `ressources=` omitted).
47 + func syncNow(
48 + bindings: [CalendarBinding] = [],
49 + bridge: CalendarBridge? = nil
50 + ) async throws -> SyncResult {
51 + let agendaEnabled = bridge != nil && !bindings.isEmpty
52 + if agendaEnabled {
53 + try await agenda.agendaToRoom(bindings: bindings, bridge: bridge!)
54 + }
55 +
56 + let conflicts = try await pushOps()
57 + let ressourcesQuery = agendaEnabled ? agenda.ressourcesQuery(bindings) : nil
58 +
59 + switch await api.syncPull(sinceIso: try await watermark(), ressourcesQuery: ressourcesQuery) {
60 + case .ok(let pull):
61 + try await applyPull(pull)
62 + if agendaEnabled {
63 + try await agenda.roomToAgenda(bindings: bindings, bridge: bridge!)
64 + }
65 + try await setWatermark(pull.serverTime)
66 + return SyncResult(success: true, serverTime: pull.serverTime, conflicts: conflicts)
67 + case .err(_, let message):
68 + return SyncResult(success: false, error: message, conflicts: conflicts)
69 + }
70 + }
71 +
72 + /// Count of local items not yet pushed (« calendrier local en avance » banner).
73 + /// With `bindings`/`bridge`: adds card2vcf-tagged calendar events without a local entity
74 + /// (orphans not yet cleaned up on the calendar side, see `AgendaSyncCoordinator.orphanEventCount`).
75 + func localAheadCount(
76 + bindings: [CalendarBinding] = [],
77 + bridge: CalendarBridge? = nil
78 + ) async throws -> Int {
79 + let dirtyRdv = try await db.rdvDao.listDirty().count
80 + let dirtyReservations = try await db.reservationDao.listDirty().count
81 + let pendingAgendaOps = try await db.syncOpDao.listAll().filter {
82 + $0.entityType == AgendaSyncCoordinator.kindRdv || $0.entityType == AgendaSyncCoordinator.entityReservation
83 + }.count
84 + let orphanEvents: Int
85 + if let bridge, !bindings.isEmpty {
86 + orphanEvents = try await agenda.orphanEventCount(bindings: bindings, bridge: bridge)
87 + } else {
88 + orphanEvents = 0
89 + }
90 + return dirtyRdv + dirtyReservations + pendingAgendaOps + orphanEvents
91 + }
92 +
93 + /// The user gives up their conflicting reservation: purge entity + SyncOp + calendar event.
94 + func abandonLocalReservation(localId: Int64, bridge: CalendarBridge? = nil) async throws {
95 + guard let reservation = try await db.reservationDao.getByLocalId(localId) else { return }
96 + if let eventId = reservation.calendarEventId, let bridge {
97 + try bridge.deleteEvent(eventId: eventId)
98 + }
99 + for op in try await db.syncOpDao.listAll()
100 + where op.entityType == AgendaSyncCoordinator.entityReservation && op.localId == localId {
101 + try await db.syncOpDao.deleteById(op.id)
102 + }
103 + try await db.reservationDao.deleteByLocalId(localId)
104 + }
105 +
106 + // ---- watermark ----
107 +
108 + private func watermark() async throws -> String {
109 + try await db.syncMetaDao.get(Self.watermarkKey)?.value ?? Self.defaultWatermark
110 + }
111 +
112 + private func setWatermark(_ serverTime: String) async throws {
113 + try await db.syncMetaDao.upsert(SyncMetaEntity(key: Self.watermarkKey, value: serverTime))
114 + }
115 +
116 + // ---- push ----
117 +
118 + /// Outcome of a pushed op: `conflict` is non-nil only for a reservation 409 (`ok` stays `false`).
119 + private struct PushOutcome {
120 + var ok: Bool
121 + var conflict: AgendaConflict? = nil
122 + }
123 +
124 + private func pushOps() async throws -> [AgendaConflict] {
125 + var conflicts: [AgendaConflict] = []
126 + for op in try await db.syncOpDao.listAll() {
127 + let outcome: PushOutcome
128 + switch op.entityType {
129 + case "contact":
130 + outcome = PushOutcome(ok: try await pushContactOp(op))
131 + case Self.entityContactMedia:
132 + outcome = PushOutcome(ok: try await pushContactMediaOp(op))
133 + case "entreprise":
134 + outcome = PushOutcome(ok: try await pushEntrepriseOp(op))
135 + case "projet":
136 + outcome = PushOutcome(ok: try await pushProjetOp(op))
137 + case "tache":
138 + outcome = PushOutcome(ok: try await pushTacheOp(op))
139 + case "interaction":
140 + outcome = PushOutcome(ok: try await pushInteractionOp(op))
141 + case AgendaSyncCoordinator.kindRdv:
142 + outcome = PushOutcome(ok: try await pushRdvOp(op))
143 + case AgendaSyncCoordinator.entityReservation:
144 + outcome = try await pushReservationOp(op)
145 + default:
146 + outcome = PushOutcome(ok: true)
147 + }
148 + if outcome.ok {
149 + try await db.syncOpDao.deleteById(op.id)
150 + }
151 + if let conflict = outcome.conflict {
152 + conflicts.append(conflict)
153 + }
154 + }
155 + return conflicts
156 + }
157 +
158 + private func pushContactOp(_ op: SyncOpEntity) async throws -> Bool {
159 + switch op.op {
160 + case "create":
161 + let result = await api.createContact(jsonBody: op.payloadJson)
162 + if case .ok(let body) = result {
163 + if let newServerId = Self.parseCreatedId(body), let localId = op.localId {
164 + if var contact = try await db.crmContactDao.getById(localId) {
165 + contact.serverId = newServerId
166 + try await db.crmContactDao.update(contact)
167 + }
168 + if try await !pushContactImages(localId: localId, serverId: newServerId) {
169 + try await enqueueContactMediaRetry(localId: localId, serverId: newServerId)
170 + }
171 + }
172 + }
173 + return result.isOk
174 + 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 }
177 + let localId: Int64?
178 + if let opLocalId = op.localId {
179 + localId = opLocalId
180 + } else {
181 + localId = try await db.crmContactDao.getByServerId(serverId)?.id
182 + }
183 + if let localId, try await !pushContactImages(localId: localId, serverId: serverId) {
184 + try await enqueueContactMediaRetry(localId: localId, serverId: serverId)
185 + }
186 + return true
187 + case "delete":
188 + guard let serverId = op.serverId else { return false }
189 + return await api.deleteContact(id: serverId).isOk
190 + default:
191 + return true
192 + }
193 + }
194 +
195 + private func pushContactMediaOp(_ op: SyncOpEntity) async throws -> Bool {
196 + guard let serverId = op.serverId else { return true }
197 + let localId: Int64?
198 + if let opLocalId = op.localId {
199 + localId = opLocalId
200 + } else {
201 + localId = try await db.crmContactDao.getByServerId(serverId)?.id
202 + }
203 + guard let localId else { return true }
204 + return try await pushContactImages(localId: localId, serverId: serverId)
205 + }
206 +
207 + /// Uploads the local card/photo files to the server. `true` when nothing to send or all OK.
208 + /// `false` when at least one present file failed to upload (retry via `contact_media`).
209 + private func pushContactImages(localId: Int64, serverId: String) async throws -> Bool {
210 + guard let contact = try await db.crmContactDao.getById(localId) else { return true }
211 + var ok = true
212 + if let cardPath = nonBlank(contact.cardImagePath), let bytes = Self.readFile(cardPath) {
213 + let result = await api.uploadContactCarte(id: serverId, bytes: bytes, filename: "card.jpg")
214 + if !result.isOk { ok = false }
215 + }
216 + if let profilePath = nonBlank(contact.profileImagePath), let bytes = Self.readFile(profilePath) {
217 + let result = await api.uploadContactPhoto(id: serverId, bytes: bytes, filename: "profile.jpg")
218 + if !result.isOk { ok = false }
219 + }
220 + return ok
221 + }
222 +
223 + private func enqueueContactMediaRetry(localId: Int64, serverId: String) async throws {
224 + let already = try await db.syncOpDao.listAll().contains {
225 + $0.entityType == Self.entityContactMedia && $0.localId == localId && $0.serverId == serverId
226 + }
227 + if already { return }
228 + var entity = SyncOpEntity(entityType: Self.entityContactMedia, op: "upload")
229 + entity.payloadJson = "{}"
230 + entity.localId = localId
231 + entity.serverId = serverId
232 + entity.createdAt = AgendaSyncCoordinator.currentMillis()
233 + try await db.syncOpDao.insert(entity)
234 + }
235 +
236 + private func pushEntrepriseOp(_ op: SyncOpEntity) async throws -> Bool {
237 + switch op.op {
238 + case "create":
239 + let result = await api.createEntreprise(jsonBody: op.payloadJson)
240 + if case .ok(let body) = result {
241 + if let newServerId = Self.parseCreatedId(body), let localId = op.localId {
242 + if var entity = try await db.entrepriseDao.getByLocalId(localId) {
243 + entity.serverId = newServerId
244 + try await db.entrepriseDao.upsert(entity)
245 + }
246 + }
247 + }
248 + return result.isOk
249 + case "update":
250 + guard let serverId = op.serverId else { return false }
251 + return await api.updateEntreprise(id: serverId, jsonBody: op.payloadJson).isOk
252 + case "delete":
253 + guard let serverId = op.serverId else { return false }
254 + return await api.deleteEntreprise(id: serverId).isOk
255 + default:
256 + return true
257 + }
258 + }
259 +
260 + private func pushProjetOp(_ op: SyncOpEntity) async throws -> Bool {
261 + switch op.op {
262 + case "create":
263 + let result = await api.createProjet(jsonBody: op.payloadJson)
264 + if case .ok(let body) = result {
265 + if let newServerId = Self.parseCreatedId(body), let localId = op.localId {
266 + if var entity = try await db.projetDao.getByLocalId(localId) {
267 + entity.serverId = newServerId
268 + try await db.projetDao.upsert(entity)
269 + }
270 + }
271 + }
272 + return result.isOk
273 + case "update":
274 + guard let serverId = op.serverId else { return false }
275 + return await api.updateProjet(id: serverId, jsonBody: op.payloadJson).isOk
276 + case "delete":
277 + guard let serverId = op.serverId else { return false }
278 + return await api.deleteProjet(id: serverId).isOk
279 + default:
280 + return true
281 + }
282 + }
283 +
284 + /// Resolves the `projetServerId` of a task via `op.localId` (create) or `op.serverId` (update/delete/move).
285 + private func resolveTacheProjetId(_ op: SyncOpEntity) async throws -> String? {
286 + if let localId = op.localId, let tache = try await db.tacheDao.getByLocalId(localId) {
287 + return tache.projetServerId
288 + }
289 + if let serverId = op.serverId, let tache = try await db.tacheDao.getByServerId(serverId) {
290 + return tache.projetServerId
291 + }
292 + return nil
293 + }
294 +
295 + private func pushTacheOp(_ op: SyncOpEntity) async throws -> Bool {
296 + guard let projetId = try await resolveTacheProjetId(op) else { return false }
297 + switch op.op {
298 + case "create":
299 + let result = await api.createTache(projetId: projetId, jsonBody: op.payloadJson)
300 + if case .ok(let body) = result {
301 + if let newServerId = Self.parseCreatedId(body), let localId = op.localId {
302 + if var entity = try await db.tacheDao.getByLocalId(localId) {
303 + entity.serverId = newServerId
304 + try await db.tacheDao.upsert(entity)
305 + }
306 + }
307 + }
308 + return result.isOk
309 + case "update":
310 + guard let serverId = op.serverId else { return false }
311 + return await api.updateTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson).isOk
312 + case "delete":
313 + guard let serverId = op.serverId else { return false }
314 + return await api.deleteTache(projetId: projetId, tacheId: serverId).isOk
315 + case "move":
316 + guard let serverId = op.serverId else { return false }
317 + return await api.moveTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson).isOk
318 + default:
319 + return true
320 + }
321 + }
322 +
323 + /// `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
328 + }
329 +
330 + private func pushRdvOp(_ op: SyncOpEntity) async throws -> Bool {
331 + switch op.op {
332 + case "create":
333 + let result = await api.createRdv(jsonBody: op.payloadJson)
334 + if case .ok(let body) = result {
335 + if let newServerId = Self.parseCreatedId(body), let localId = op.localId {
336 + if var entity = try await db.rdvDao.getByLocalId(localId) {
337 + entity.serverId = newServerId
338 + entity.dirtyLocal = false
339 + try await db.rdvDao.upsert(entity)
340 + }
341 + }
342 + }
343 + return result.isOk
344 + 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) {
348 + entity.dirtyLocal = false
349 + try await db.rdvDao.upsert(entity)
350 + }
351 + return ok
352 + case "delete":
353 + guard let serverId = op.serverId else { return false }
354 + return await api.deleteRdv(id: serverId).isOk
355 + default:
356 + return true
357 + }
358 + }
359 +
360 + /// Resolves `(cibleType, cibleId)` via `op.localId` (create) or `op.serverId` (update/delete),
361 + /// falling back to `op.payloadJson` (a `delete` detected after the calendar-side removal has
362 + /// already deleted the local entity; see `AgendaSyncCoordinator.cibleRefPayload`).
363 + private func resolveReservationCible(_ op: SyncOpEntity) async throws -> (String, String)? {
364 + if let localId = op.localId, let reservation = try await db.reservationDao.getByLocalId(localId) {
365 + return (reservation.cibleType, reservation.cibleId)
366 + }
367 + if let serverId = op.serverId, let reservation = try await db.reservationDao.getByServerId(serverId) {
368 + return (reservation.cibleType, reservation.cibleId)
369 + }
370 + return Self.parseCibleRef(op.payloadJson)
371 + }
372 +
373 + /// On 409 (server-side overlap): keep the op queued (see `pushOps`), flag `conflictPending`
374 + /// on the local entity and surface an `AgendaConflict` for the « Annuler ma réservation ? » dialog.
375 + private func pushReservationOp(_ op: SyncOpEntity) async throws -> PushOutcome {
376 + guard let (cibleType, cibleId) = try await resolveReservationCible(op) else {
377 + return PushOutcome(ok: false)
378 + }
379 + switch op.op {
380 + case "create":
381 + let result = await api.createReservation(genre: cibleType, resourceId: cibleId, jsonBody: op.payloadJson)
382 + return try await handleReservationPushResult(result, op: op)
383 + case "update":
384 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
385 + let result = await api.updateReservation(
386 + genre: cibleType,
387 + resourceId: cibleId,
388 + reservationId: serverId,
389 + jsonBody: op.payloadJson
390 + )
391 + return try await handleReservationPushResult(result, op: op)
392 + case "delete":
393 + 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)
396 + default:
397 + return PushOutcome(ok: true)
398 + }
399 + }
400 +
401 + private func handleReservationPushResult(
402 + _ result: AilianceApiClient.ApiResult<String>,
403 + op: SyncOpEntity
404 + ) async throws -> PushOutcome {
405 + switch result {
406 + case .ok(let body):
407 + if let localId = op.localId, var local = try await db.reservationDao.getByLocalId(localId) {
408 + if op.op == "create" {
409 + local.serverId = Self.parseCreatedId(body) ?? local.serverId
410 + }
411 + local.dirtyLocal = false
412 + try await db.reservationDao.upsert(local)
413 + }
414 + return PushOutcome(ok: true)
415 + case .err(let code, let message):
416 + if code == 409, let localId = op.localId {
417 + if var local = try await db.reservationDao.getByLocalId(localId) {
418 + local.conflictPending = true
419 + try await db.reservationDao.upsert(local)
420 + }
421 + return PushOutcome(ok: false, conflict: AgendaConflict(localReservationId: localId, message: message))
422 + }
423 + return PushOutcome(ok: false)
424 + }
425 + }
426 +
427 + // ---- pull ----
428 +
429 + private func applyPull(_ pull: SyncPullResponse) async throws {
430 + for dto in pull.contacts { try await applyContact(dto) }
431 + for dto in pull.entreprises { try await applyEntreprise(dto) }
432 + for dto in pull.projets { try await applyProjet(dto) }
433 + for dto in pull.taches { try await applyTache(dto) }
434 + for dto in pull.interactions { try await applyInteraction(dto) }
435 + for dto in pull.workflows { try await applyWorkflow(dto) }
436 + for dto in pull.rdv { try await agenda.applyRdvPull(dto) }
437 + for dto in pull.reservations { try await agenda.applyReservationPull(dto) }
438 + for dto in pull.indisponibilites { try await agenda.applyIndisponibilitePull(dto) }
439 + for dto in pull.tombstones { try await applyTombstone(dto) }
440 + }
441 +
442 + private func applyContact(_ dto: ContactDto) async throws {
443 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
444 + let local = try await db.crmContactDao.getByServerId(dto.id)
445 + guard let local else {
446 + var entity = CrmContactEntity()
447 + entity.serverId = dto.id
448 + entity.fullName = "\(dto.prenom) \(dto.nom)".trimmingCharacters(in: .whitespaces)
449 + entity.firstName = dto.prenom
450 + entity.lastName = dto.nom
451 + entity.jobTitle = dto.fonction
452 + entity.statut = dto.statut
453 + entity.etape = dto.etape
454 + entity.tags = dto.tags
455 + entity.entrepriseServerId = dto.entrepriseId
456 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
457 + entity.updatedAt = remoteTs
458 + let newId = try await db.crmContactDao.insert(entity)
459 + try await pullContactImages(localId: newId, dto: dto, remoteTs: remoteTs, localUpdatedAt: 0)
460 + return
461 + }
462 + var remoteEntity = local
463 + remoteEntity.fullName = "\(dto.prenom) \(dto.nom)".trimmingCharacters(in: .whitespaces)
464 + remoteEntity.firstName = dto.prenom
465 + remoteEntity.lastName = dto.nom
466 + remoteEntity.jobTitle = dto.fonction
467 + remoteEntity.statut = dto.statut
468 + remoteEntity.etape = dto.etape
469 + remoteEntity.tags = dto.tags
470 + remoteEntity.entrepriseServerId = dto.entrepriseId
471 + remoteEntity.updatedAt = remoteTs
472 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
473 + if merged != local {
474 + try await db.crmContactDao.update(merged)
475 + }
476 + try await pullContactImages(localId: local.id, dto: dto, remoteTs: remoteTs, localUpdatedAt: local.updatedAt)
477 + }
478 +
479 + /// Downloads card/photo when present server-side and (no local file, or server newer),
480 + /// unless a contact/media push is still queued.
481 + private func pullContactImages(
482 + localId: Int64,
483 + dto: ContactDto,
484 + remoteTs: Int64,
485 + localUpdatedAt: Int64
486 + ) async throws {
487 + guard let store = imageStore else { return }
488 + if try await hasPendingContactPush(localId: localId, serverId: dto.id) { return }
489 + guard var contact = try await db.crmContactDao.getById(localId) else { return }
490 + let serverNewer = remoteTs > localUpdatedAt
491 +
492 + if let carteVisite = dto.carteVisite, !carteVisite.isEmpty {
493 + let path = contact.cardImagePath
494 + let missing = path == nil || path!.isEmpty || !Self.isFile(path!)
495 + if missing || serverNewer {
496 + if case .ok(let bytes) = await api.downloadContactCarte(id: dto.id) {
497 + let saved = try store.saveBytes(contactId: localId, name: "card.jpg", bytes: bytes)
498 + contact.cardImagePath = saved
499 + try await db.crmContactDao.update(contact)
500 + }
501 + }
502 + }
503 + if let photo = dto.photo, !photo.isEmpty {
504 + let path = contact.profileImagePath
505 + let missing = path == nil || path!.isEmpty || !Self.isFile(path!)
506 + if missing || serverNewer {
507 + if case .ok(let bytes) = await api.downloadContactPhoto(id: dto.id) {
508 + let saved = try store.saveBytes(contactId: localId, name: "profile.jpg", bytes: bytes)
509 + contact.profileImagePath = saved
510 + try await db.crmContactDao.update(contact)
511 + }
512 + }
513 + }
514 + }
515 +
516 + private func hasPendingContactPush(localId: Int64, serverId: String) async throws -> Bool {
517 + try await db.syncOpDao.listAll().contains { op in
518 + switch op.entityType {
519 + case "contact", Self.entityContactMedia:
520 + return (op.localId == localId || op.serverId == serverId) && op.op != "delete"
521 + default:
522 + return false
523 + }
524 + }
525 + }
526 +
527 + private func applyEntreprise(_ dto: EntrepriseDto) async throws {
528 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
529 + let local = try await db.entrepriseDao.getByServerId(dto.id)
530 + guard let local else {
531 + var entity = EntrepriseEntity()
532 + entity.serverId = dto.id
533 + entity.nom = dto.nom
534 + entity.secteur = dto.secteur
535 + entity.creePar = dto.creePar
536 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
537 + entity.updatedAt = remoteTs
538 + try await db.entrepriseDao.upsert(entity)
539 + return
540 + }
541 + var remoteEntity = local
542 + remoteEntity.nom = dto.nom
543 + remoteEntity.secteur = dto.secteur
544 + remoteEntity.creePar = dto.creePar
545 + remoteEntity.updatedAt = remoteTs
546 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
547 + if merged != local {
548 + try await db.entrepriseDao.upsert(merged)
549 + }
550 + }
551 +
552 + private func applyProjet(_ dto: ProjetDto) async throws {
553 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
554 + let local = try await db.projetDao.getByServerId(dto.id)
555 + let membresJson = SyncJson.encodeToString(dto.membres)
556 + guard let local else {
557 + var entity = ProjetEntity()
558 + entity.serverId = dto.id
559 + entity.nom = dto.nom
560 + entity.description = dto.description
561 + entity.workflowServerId = dto.workflowId
562 + entity.membresJson = membresJson
563 + entity.creePar = dto.creePar
564 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
565 + entity.updatedAt = remoteTs
566 + try await db.projetDao.upsert(entity)
567 + return
568 + }
569 + var remoteEntity = local
570 + remoteEntity.nom = dto.nom
571 + remoteEntity.description = dto.description
572 + remoteEntity.workflowServerId = dto.workflowId
573 + remoteEntity.membresJson = membresJson
574 + remoteEntity.creePar = dto.creePar
575 + remoteEntity.updatedAt = remoteTs
576 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
577 + if merged != local {
578 + try await db.projetDao.upsert(merged)
579 + }
580 + }
581 +
582 + private func applyTache(_ dto: TacheSyncDto) async throws {
583 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
584 + let local = try await db.tacheDao.getByServerId(dto.id)
585 + let projetLocalId = try await db.projetDao.getByServerId(dto.projetId)?.localId
586 + guard let local else {
587 + var entity = TacheEntity()
588 + entity.serverId = dto.id
589 + entity.projetLocalId = projetLocalId
590 + entity.projetServerId = dto.projetId
591 + entity.titre = dto.titre
592 + entity.description = dto.description
593 + entity.colonneId = dto.colonneId
594 + entity.ordre = dto.ordre
595 + entity.assigneA = dto.assigneA
596 + entity.auteur = dto.auteur
597 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
598 + entity.updatedAt = remoteTs
599 + try await db.tacheDao.upsert(entity)
600 + return
601 + }
602 + var remoteEntity = local
603 + remoteEntity.projetLocalId = projetLocalId ?? local.projetLocalId
604 + remoteEntity.projetServerId = dto.projetId
605 + remoteEntity.titre = dto.titre
606 + remoteEntity.description = dto.description
607 + remoteEntity.colonneId = dto.colonneId
608 + remoteEntity.ordre = dto.ordre
609 + remoteEntity.assigneA = dto.assigneA
610 + remoteEntity.auteur = dto.auteur
611 + remoteEntity.updatedAt = remoteTs
612 + let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
613 + if merged != local {
614 + try await db.tacheDao.upsert(merged)
615 + }
616 + }
617 +
618 + private func applyInteraction(_ dto: InteractionDto) async throws {
619 + let existing = Set(
620 + try await db.interactionDao.listByContactServerId(dto.contactId).compactMap { $0.serverId }
621 + )
622 + if !LwwMerger.shouldInsertInteraction(existingServerIds: existing, remoteServerId: dto.id) { return }
623 + var entity = InteractionEntity()
624 + entity.serverId = dto.id
625 + entity.contactServerId = dto.contactId
626 + entity.type = dto.typeInteraction
627 + entity.sujet = dto.sujet
628 + entity.description = dto.description
629 + entity.creePar = dto.creePar
630 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
631 + entity.updatedAt = dto.misAJourLe.map { parseIsoToEpochMs($0) }
632 + try await db.interactionDao.upsert(entity)
633 + }
634 +
635 + private func applyWorkflow(_ dto: WorkflowDto) async throws {
636 + var entity = WorkflowEntity(serverId: dto.id)
637 + entity.nom = dto.nom
638 + entity.description = dto.description
639 + entity.colonnesJson = SyncJson.encodeToString(dto.colonnes)
640 + entity.creePar = dto.creePar
641 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
642 + try await db.workflowDao.upsert(entity)
643 + }
644 +
645 + private func applyTombstone(_ dto: TombstoneDto) async throws {
646 + switch dto.entityType {
647 + case "contact":
648 + try await db.crmContactDao.deleteByServerId(dto.id)
649 + case "entreprise":
650 + try await db.entrepriseDao.deleteByServerId(dto.id)
651 + case "projet":
652 + for serverId in try await db.tacheDao.listByProjetServerId(dto.id).compactMap({ $0.serverId }) {
653 + try await db.tacheDao.deleteByServerId(serverId)
654 + }
655 + try await db.projetDao.deleteByServerId(dto.id)
656 + case "tache":
657 + try await db.tacheDao.deleteByServerId(dto.id)
658 + case "interaction":
659 + try await db.interactionDao.deleteByServerId(dto.id)
660 + default:
661 + try await agenda.applyAgendaTombstone(dto)
662 + }
663 + }
664 +
665 + // ---- helpers ----
666 +
667 + private func nonBlank(_ value: String?) -> String? {
668 + guard let value, !value.isEmpty else { return nil }
669 + return value
670 + }
671 +
672 + private static func isFile(_ path: String) -> Bool {
673 + var isDirectory: ObjCBool = false
674 + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue
675 + }
676 +
677 + private static func readFile(_ path: String) -> Data? {
678 + guard isFile(path) else { return nil }
679 + return try? Data(contentsOf: URL(fileURLWithPath: path))
680 + }
681 +
682 + private struct CreatedIdDto: Codable {
683 + var id: String? = nil
684 +
685 + init(from decoder: Decoder) throws {
686 + let c = try decoder.container(keyedBy: CodingKeys.self)
687 + id = try c.decodeIfPresent(String.self, forKey: .id)
688 + }
689 + }
690 +
691 + static func parseCreatedId(_ body: String) -> String? {
692 + (try? SyncJson.decode(CreatedIdDto.self, from: body))?.id
693 + }
694 +
695 + static func parseCibleRef(_ payloadJson: String) -> (String, String)? {
696 + guard let ref = try? SyncJson.decode(CibleRessourceDto.self, from: payloadJson) else { return nil }
697 + return (ref.type, ref.id)
698 + }
699 +}
A ios/Card2vcf/Sync/SyncModels.swift
+688 -0
@@ -0,0 +1,688 @@
1 +import Foundation
2 +
3 +/// Shared JSON config: snake_case wire format (Projectiaon server), unknown fields ignored.
4 +/// Mirror of Kotlin `syncJson` (`JsonNamingStrategy.SnakeCase`, `ignoreUnknownKeys`, `encodeDefaults`).
5 +enum SyncJson {
6 + static func encoder() -> JSONEncoder {
7 + let encoder = JSONEncoder()
8 + encoder.keyEncodingStrategy = .convertToSnakeCase
9 + return encoder
10 + }
11 +
12 + static func decoder() -> JSONDecoder {
13 + let decoder = JSONDecoder()
14 + decoder.keyDecodingStrategy = .convertFromSnakeCase
15 + return decoder
16 + }
17 +
18 + static func encodeToString<T: Encodable>(_ value: T) -> String {
19 + guard let data = try? encoder().encode(value) else { return "{}" }
20 + return String(data: data, encoding: .utf8) ?? "{}"
21 + }
22 +
23 + static func decode<T: Decodable>(_ type: T.Type, from string: String) throws -> T {
24 + try decoder().decode(type, from: Data(string.utf8))
25 + }
26 +}
27 +
28 +// ---- Auth clé ----
29 +
30 +struct AuthCleRequest: Codable, Equatable {
31 + var nom: String
32 + var motDePasse: String
33 +}
34 +
35 +struct AuthCleResponse: Codable, Equatable {
36 + var nom: String
37 + var cleChiffree: String
38 + var sel: String
39 + var kdf: String
40 + var cipher: String
41 +}
42 +
43 +// ---- Sync status / pull ----
44 +
45 +struct SyncChanges: Codable, Equatable {
46 + var contacts: Int = 0
47 + var entreprises: Int = 0
48 + var projets: Int = 0
49 + var taches: Int = 0
50 + var interactions: Int = 0
51 + var rdv: Int = 0
52 + var reservations: Int = 0
53 + var indisponibilites: Int = 0
54 + var tombstones: Int = 0
55 +}
56 +
57 +extension SyncChanges {
58 + init(from decoder: Decoder) throws {
59 + let c = try decoder.container(keyedBy: CodingKeys.self)
60 + contacts = try c.decodeIfPresent(Int.self, forKey: .contacts) ?? 0
61 + entreprises = try c.decodeIfPresent(Int.self, forKey: .entreprises) ?? 0
62 + projets = try c.decodeIfPresent(Int.self, forKey: .projets) ?? 0
63 + taches = try c.decodeIfPresent(Int.self, forKey: .taches) ?? 0
64 + interactions = try c.decodeIfPresent(Int.self, forKey: .interactions) ?? 0
65 + rdv = try c.decodeIfPresent(Int.self, forKey: .rdv) ?? 0
66 + reservations = try c.decodeIfPresent(Int.self, forKey: .reservations) ?? 0
67 + indisponibilites = try c.decodeIfPresent(Int.self, forKey: .indisponibilites) ?? 0
68 + tombstones = try c.decodeIfPresent(Int.self, forKey: .tombstones) ?? 0
69 + }
70 +}
71 +
72 +struct SyncStatusResponse: Codable, Equatable {
73 + var serverTime: String
74 + var changes: SyncChanges
75 + var total: Int
76 +}
77 +
78 +struct SyncPullResponse: Codable, Equatable {
79 + var serverTime: String
80 + var contacts: [ContactDto] = []
81 + var entreprises: [EntrepriseDto] = []
82 + var projets: [ProjetDto] = []
83 + var taches: [TacheSyncDto] = []
84 + var interactions: [InteractionDto] = []
85 + var rdv: [RendezVousDto] = []
86 + var reservations: [ReservationDto] = []
87 + var indisponibilites: [IndisponibiliteDto] = []
88 + var tombstones: [TombstoneDto] = []
89 + var workflows: [WorkflowDto] = []
90 +}
91 +
92 +extension SyncPullResponse {
93 + init(from decoder: Decoder) throws {
94 + let c = try decoder.container(keyedBy: CodingKeys.self)
95 + serverTime = try c.decode(String.self, forKey: .serverTime)
96 + contacts = try c.decodeIfPresent([ContactDto].self, forKey: .contacts) ?? []
97 + entreprises = try c.decodeIfPresent([EntrepriseDto].self, forKey: .entreprises) ?? []
98 + projets = try c.decodeIfPresent([ProjetDto].self, forKey: .projets) ?? []
99 + taches = try c.decodeIfPresent([TacheSyncDto].self, forKey: .taches) ?? []
100 + interactions = try c.decodeIfPresent([InteractionDto].self, forKey: .interactions) ?? []
101 + rdv = try c.decodeIfPresent([RendezVousDto].self, forKey: .rdv) ?? []
102 + reservations = try c.decodeIfPresent([ReservationDto].self, forKey: .reservations) ?? []
103 + indisponibilites = try c.decodeIfPresent([IndisponibiliteDto].self, forKey: .indisponibilites) ?? []
104 + tombstones = try c.decodeIfPresent([TombstoneDto].self, forKey: .tombstones) ?? []
105 + workflows = try c.decodeIfPresent([WorkflowDto].self, forKey: .workflows) ?? []
106 + }
107 +}
108 +
109 +struct TombstoneDto: Codable, Equatable {
110 + var entityType: String
111 + var id: String
112 + var projetId: String? = nil
113 + var cibleType: String? = nil
114 + var cibleId: String? = nil
115 + var utilisateur: String? = nil
116 + var supprimeLe: String
117 +
118 + enum CodingKeys: String, CodingKey {
119 + // Kotlin `@SerialName("type")`.
120 + case entityType = "type"
121 + case id
122 + case projetId
123 + case cibleType
124 + case cibleId
125 + case utilisateur
126 + case supprimeLe
127 + }
128 +}
129 +
130 +extension TombstoneDto {
131 + init(from decoder: Decoder) throws {
132 + let c = try decoder.container(keyedBy: CodingKeys.self)
133 + entityType = try c.decode(String.self, forKey: .entityType)
134 + id = try c.decode(String.self, forKey: .id)
135 + projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
136 + cibleType = try c.decodeIfPresent(String.self, forKey: .cibleType)
137 + cibleId = try c.decodeIfPresent(String.self, forKey: .cibleId)
138 + utilisateur = try c.decodeIfPresent(String.self, forKey: .utilisateur)
139 + supprimeLe = try c.decode(String.self, forKey: .supprimeLe)
140 + }
141 +}
142 +
143 +// ---- Entités sync (champs minimaux pour LWW / merge) ----
144 +
145 +struct ContactDto: Codable, Equatable {
146 + var id: String
147 + var prenom: String = ""
148 + var nom: String = ""
149 + var entrepriseId: String? = nil
150 + var fonction: String = ""
151 + var statut: String = "prospect"
152 + var etape: String = "nouveau"
153 + var tags: [String] = []
154 + var creePar: String = ""
155 + var creeLe: String
156 + var misAJourLe: String? = nil
157 + /// Server-side file name (`carte.jpg`), not the bytes.
158 + var carteVisite: String? = nil
159 + /// Server-side file name (`photo.jpg`), not the bytes.
160 + var photo: String? = nil
161 +}
162 +
163 +extension ContactDto {
164 + init(from decoder: Decoder) throws {
165 + let c = try decoder.container(keyedBy: CodingKeys.self)
166 + id = try c.decode(String.self, forKey: .id)
167 + prenom = try c.decodeIfPresent(String.self, forKey: .prenom) ?? ""
168 + nom = try c.decodeIfPresent(String.self, forKey: .nom) ?? ""
169 + entrepriseId = try c.decodeIfPresent(String.self, forKey: .entrepriseId)
170 + fonction = try c.decodeIfPresent(String.self, forKey: .fonction) ?? ""
171 + statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "prospect"
172 + etape = try c.decodeIfPresent(String.self, forKey: .etape) ?? "nouveau"
173 + tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
174 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
175 + creeLe = try c.decode(String.self, forKey: .creeLe)
176 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
177 + carteVisite = try c.decodeIfPresent(String.self, forKey: .carteVisite)
178 + photo = try c.decodeIfPresent(String.self, forKey: .photo)
179 + }
180 +}
181 +
182 +struct EntrepriseDto: Codable, Equatable {
183 + var id: String
184 + var nom: String
185 + var secteur: String = ""
186 + var creePar: String = ""
187 + var creeLe: String
188 + var misAJourLe: String? = nil
189 +}
190 +
191 +extension EntrepriseDto {
192 + init(from decoder: Decoder) throws {
193 + let c = try decoder.container(keyedBy: CodingKeys.self)
194 + id = try c.decode(String.self, forKey: .id)
195 + nom = try c.decode(String.self, forKey: .nom)
196 + secteur = try c.decodeIfPresent(String.self, forKey: .secteur) ?? ""
197 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
198 + creeLe = try c.decode(String.self, forKey: .creeLe)
199 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
200 + }
201 +}
202 +
203 +struct MembreProjetDto: Codable, Equatable {
204 + var utilisateur: String
205 + var niveau: String = "lecteur"
206 +}
207 +
208 +extension MembreProjetDto {
209 + init(from decoder: Decoder) throws {
210 + let c = try decoder.container(keyedBy: CodingKeys.self)
211 + utilisateur = try c.decode(String.self, forKey: .utilisateur)
212 + niveau = try c.decodeIfPresent(String.self, forKey: .niveau) ?? "lecteur"
213 + }
214 +}
215 +
216 +struct ProjetDto: Codable, Equatable {
217 + var id: String
218 + var nom: String
219 + var description: String = ""
220 + var workflowId: String
221 + var creePar: String = ""
222 + var creeLe: String
223 + var membres: [MembreProjetDto] = []
224 + var misAJourLe: String? = nil
225 +}
226 +
227 +extension ProjetDto {
228 + init(from decoder: Decoder) throws {
229 + let c = try decoder.container(keyedBy: CodingKeys.self)
230 + id = try c.decode(String.self, forKey: .id)
231 + nom = try c.decode(String.self, forKey: .nom)
232 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
233 + workflowId = try c.decode(String.self, forKey: .workflowId)
234 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
235 + creeLe = try c.decode(String.self, forKey: .creeLe)
236 + membres = try c.decodeIfPresent([MembreProjetDto].self, forKey: .membres) ?? []
237 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
238 + }
239 +}
240 +
241 +/// Flattened task: `projet_id` + Tache fields at the same level (mirror of Rust `TacheSync`).
242 +struct TacheSyncDto: Codable, Equatable {
243 + var projetId: String
244 + var id: String
245 + var titre: String
246 + var description: String = ""
247 + var colonneId: String
248 + var ordre: Int = 0
249 + var auteur: String = ""
250 + var creeLe: String
251 + var assigneA: String? = nil
252 + var misAJourLe: String? = nil
253 +}
254 +
255 +extension TacheSyncDto {
256 + init(from decoder: Decoder) throws {
257 + let c = try decoder.container(keyedBy: CodingKeys.self)
258 + projetId = try c.decode(String.self, forKey: .projetId)
259 + id = try c.decode(String.self, forKey: .id)
260 + titre = try c.decode(String.self, forKey: .titre)
261 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
262 + colonneId = try c.decode(String.self, forKey: .colonneId)
263 + ordre = try c.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
264 + auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? ""
265 + creeLe = try c.decode(String.self, forKey: .creeLe)
266 + assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
267 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
268 + }
269 +}
270 +
271 +struct InteractionDto: Codable, Equatable {
272 + var id: String
273 + var contactId: String
274 + var typeInteraction: String = "note"
275 + var sujet: String = ""
276 + var description: String = ""
277 + var creePar: String = ""
278 + var creeLe: String
279 + var misAJourLe: String? = nil
280 +}
281 +
282 +extension InteractionDto {
283 + init(from decoder: Decoder) throws {
284 + let c = try decoder.container(keyedBy: CodingKeys.self)
285 + id = try c.decode(String.self, forKey: .id)
286 + contactId = try c.decode(String.self, forKey: .contactId)
287 + typeInteraction = try c.decodeIfPresent(String.self, forKey: .typeInteraction) ?? "note"
288 + sujet = try c.decodeIfPresent(String.self, forKey: .sujet) ?? ""
289 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
290 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
291 + creeLe = try c.decode(String.self, forKey: .creeLe)
292 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
293 + }
294 +}
295 +
296 +struct ColonneDto: Codable, Equatable {
297 + var id: String
298 + var nom: String
299 + var ordre: Int = 0
300 +}
301 +
302 +extension ColonneDto {
303 + init(from decoder: Decoder) throws {
304 + let c = try decoder.container(keyedBy: CodingKeys.self)
305 + id = try c.decode(String.self, forKey: .id)
306 + nom = try c.decode(String.self, forKey: .nom)
307 + ordre = try c.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
308 + }
309 +}
310 +
311 +/// Reservation/unavailability target (mirror of Rust enum `CibleRessource`, tag `type` + `id`).
312 +struct CibleRessourceDto: Codable, Equatable {
313 + var type: String
314 + var id: String
315 +}
316 +
317 +struct RendezVousDto: Codable, Equatable {
318 + var id: String
319 + var titre: String = ""
320 + var description: String = ""
321 + var utilisateur: String = ""
322 + var contactIds: [String] = []
323 + var lieu: String = ""
324 + var debut: String
325 + var fin: String
326 + var projetId: String? = nil
327 + var creePar: String = ""
328 + var creeLe: String
329 + var misAJourLe: String? = nil
330 +}
331 +
332 +extension RendezVousDto {
333 + init(from decoder: Decoder) throws {
334 + let c = try decoder.container(keyedBy: CodingKeys.self)
335 + id = try c.decode(String.self, forKey: .id)
336 + titre = try c.decodeIfPresent(String.self, forKey: .titre) ?? ""
337 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
338 + utilisateur = try c.decodeIfPresent(String.self, forKey: .utilisateur) ?? ""
339 + contactIds = try c.decodeIfPresent([String].self, forKey: .contactIds) ?? []
340 + lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
341 + debut = try c.decode(String.self, forKey: .debut)
342 + fin = try c.decode(String.self, forKey: .fin)
343 + projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
344 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
345 + creeLe = try c.decode(String.self, forKey: .creeLe)
346 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
347 + }
348 +}
349 +
350 +struct ReservationDto: Codable, Equatable {
351 + var id: String
352 + var cible: CibleRessourceDto
353 + var debut: String
354 + var fin: String
355 + var motif: String = ""
356 + var projetId: String? = nil
357 + var rdvId: String? = nil
358 + var statut: String = "active"
359 + var reservePar: String = ""
360 + var creeLe: String
361 + var misAJourLe: String? = nil
362 +}
363 +
364 +extension ReservationDto {
365 + init(from decoder: Decoder) throws {
366 + let c = try decoder.container(keyedBy: CodingKeys.self)
367 + id = try c.decode(String.self, forKey: .id)
368 + cible = try c.decode(CibleRessourceDto.self, forKey: .cible)
369 + debut = try c.decode(String.self, forKey: .debut)
370 + fin = try c.decode(String.self, forKey: .fin)
371 + motif = try c.decodeIfPresent(String.self, forKey: .motif) ?? ""
372 + projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
373 + rdvId = try c.decodeIfPresent(String.self, forKey: .rdvId)
374 + statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "active"
375 + reservePar = try c.decodeIfPresent(String.self, forKey: .reservePar) ?? ""
376 + creeLe = try c.decode(String.self, forKey: .creeLe)
377 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
378 + }
379 +}
380 +
381 +struct IndisponibiliteDto: Codable, Equatable {
382 + var id: String
383 + var cible: CibleRessourceDto
384 + var nature: String
385 + var debut: String
386 + var fin: String
387 + var commentaire: String = ""
388 + var creePar: String = ""
389 + var creeLe: String
390 + var misAJourLe: String? = nil
391 +}
392 +
393 +extension IndisponibiliteDto {
394 + init(from decoder: Decoder) throws {
395 + let c = try decoder.container(keyedBy: CodingKeys.self)
396 + id = try c.decode(String.self, forKey: .id)
397 + cible = try c.decode(CibleRessourceDto.self, forKey: .cible)
398 + nature = try c.decode(String.self, forKey: .nature)
399 + debut = try c.decode(String.self, forKey: .debut)
400 + fin = try c.decode(String.self, forKey: .fin)
401 + commentaire = try c.decodeIfPresent(String.self, forKey: .commentaire) ?? ""
402 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
403 + creeLe = try c.decode(String.self, forKey: .creeLe)
404 + misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
405 + }
406 +}
407 +
408 +/// Lightweight catalogue resource (salle/matériel/véhicule): shared fields only.
409 +struct RessourceItemDto: Codable, Equatable {
410 + var id: String
411 + var nom: String
412 + var description: String = ""
413 + var lieu: String = ""
414 + var actif: Bool = true
415 +}
416 +
417 +extension RessourceItemDto {
418 + init(from decoder: Decoder) throws {
419 + let c = try decoder.container(keyedBy: CodingKeys.self)
420 + id = try c.decode(String.self, forKey: .id)
421 + nom = try c.decode(String.self, forKey: .nom)
422 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
423 + lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
424 + actif = try c.decodeIfPresent(Bool.self, forKey: .actif) ?? true
425 + }
426 +}
427 +
428 +struct RessourcesCatalogueDto: Codable, Equatable {
429 + var salles: [RessourceItemDto] = []
430 + var materiels: [RessourceItemDto] = []
431 + var vehicules: [RessourceItemDto] = []
432 +}
433 +
434 +extension RessourcesCatalogueDto {
435 + init(from decoder: Decoder) throws {
436 + let c = try decoder.container(keyedBy: CodingKeys.self)
437 + salles = try c.decodeIfPresent([RessourceItemDto].self, forKey: .salles) ?? []
438 + materiels = try c.decodeIfPresent([RessourceItemDto].self, forKey: .materiels) ?? []
439 + vehicules = try c.decodeIfPresent([RessourceItemDto].self, forKey: .vehicules) ?? []
440 + }
441 +}
442 +
443 +struct WorkflowDto: Codable, Equatable {
444 + var id: String
445 + var nom: String
446 + var description: String = ""
447 + var colonnes: [ColonneDto] = []
448 + var creePar: String = ""
449 + var creeLe: String
450 +}
451 +
452 +extension WorkflowDto {
453 + init(from decoder: Decoder) throws {
454 + let c = try decoder.container(keyedBy: CodingKeys.self)
455 + id = try c.decode(String.self, forKey: .id)
456 + nom = try c.decode(String.self, forKey: .nom)
457 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
458 + colonnes = try c.decodeIfPresent([ColonneDto].self, forKey: .colonnes) ?? []
459 + creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
460 + creeLe = try c.decode(String.self, forKey: .creeLe)
461 + }
462 +}
463 +
464 +// ---- Local mutation requests (SyncOp payloads, mirror of server `*Input`) ----
465 +// Kotlin (`encodeDefaults` + `explicitNulls`) encodes nil optionals as JSON null;
466 +// the custom `encode(to:)` below reproduces that.
467 +
468 +struct CreateTacheRequest: Codable, Equatable {
469 + var titre: String
470 + var description: String = ""
471 + var colonneId: String = ""
472 + var assigneA: String? = nil
473 +
474 + enum CodingKeys: String, CodingKey {
475 + case titre, description, colonneId, assigneA
476 + }
477 +}
478 +
479 +extension CreateTacheRequest {
480 + init(from decoder: Decoder) throws {
481 + let c = try decoder.container(keyedBy: CodingKeys.self)
482 + titre = try c.decode(String.self, forKey: .titre)
483 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
484 + colonneId = try c.decodeIfPresent(String.self, forKey: .colonneId) ?? ""
485 + assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
486 + }
487 +
488 + func encode(to encoder: Encoder) throws {
489 + var c = encoder.container(keyedBy: CodingKeys.self)
490 + try c.encode(titre, forKey: .titre)
491 + try c.encode(description, forKey: .description)
492 + try c.encode(colonneId, forKey: .colonneId)
493 + try encodeExplicitNull(assigneA, in: &c, forKey: .assigneA)
494 + }
495 +}
496 +
497 +struct UpdateTacheRequest: Codable, Equatable {
498 + var titre: String
499 + var description: String = ""
500 + var assigneA: String? = nil
501 + /// Kotlin `@SerialName("assigne_a_pose")` — matches the snake_case strategy output.
502 + var assigneAPose: Bool = true
503 +
504 + enum CodingKeys: String, CodingKey {
505 + case titre, description, assigneA, assigneAPose
506 + }
507 +}
508 +
509 +extension UpdateTacheRequest {
510 + init(from decoder: Decoder) throws {
511 + let c = try decoder.container(keyedBy: CodingKeys.self)
512 + titre = try c.decode(String.self, forKey: .titre)
513 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
514 + assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
515 + assigneAPose = try c.decodeIfPresent(Bool.self, forKey: .assigneAPose) ?? true
516 + }
517 +
518 + func encode(to encoder: Encoder) throws {
519 + var c = encoder.container(keyedBy: CodingKeys.self)
520 + try c.encode(titre, forKey: .titre)
521 + try c.encode(description, forKey: .description)
522 + try encodeExplicitNull(assigneA, in: &c, forKey: .assigneA)
523 + try c.encode(assigneAPose, forKey: .assigneAPose)
524 + }
525 +}
526 +
527 +struct MoveTacheRequest: Codable, Equatable {
528 + var colonneId: String
529 +}
530 +
531 +struct CreateInteractionRequest: Codable, Equatable {
532 + var sujet: String
533 + var description: String = ""
534 +}
535 +
536 +extension CreateInteractionRequest {
537 + init(from decoder: Decoder) throws {
538 + let c = try decoder.container(keyedBy: CodingKeys.self)
539 + sujet = try c.decode(String.self, forKey: .sujet)
540 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
541 + }
542 +}
543 +
544 +struct ContactValeurDto: Codable, Equatable {
545 + var valeur: String
546 + var categorie: String = "pro"
547 +}
548 +
549 +extension ContactValeurDto {
550 + init(from decoder: Decoder) throws {
551 + let c = try decoder.container(keyedBy: CodingKeys.self)
552 + valeur = try c.decode(String.self, forKey: .valeur)
553 + categorie = try c.decodeIfPresent(String.self, forKey: .categorie) ?? "pro"
554 + }
555 +}
556 +
557 +/// Create/update contact payload (mirror of server `ContactInput`).
558 +struct CreateContactRequest: Codable, Equatable {
559 + var prenom: String = ""
560 + var nom: String = ""
561 + var entrepriseId: String? = nil
562 + var fonction: String = ""
563 + var emails: [ContactValeurDto] = []
564 + var telephones: [ContactValeurDto] = []
565 + var notes: String = ""
566 + var statut: String = "prospect"
567 + var etape: String = "nouveau"
568 + var tags: [String] = []
569 +
570 + enum CodingKeys: String, CodingKey {
571 + case prenom, nom, entrepriseId, fonction, emails, telephones, notes, statut, etape, tags
572 + }
573 +}
574 +
575 +extension CreateContactRequest {
576 + init(from decoder: Decoder) throws {
577 + let c = try decoder.container(keyedBy: CodingKeys.self)
578 + prenom = try c.decodeIfPresent(String.self, forKey: .prenom) ?? ""
579 + nom = try c.decodeIfPresent(String.self, forKey: .nom) ?? ""
580 + entrepriseId = try c.decodeIfPresent(String.self, forKey: .entrepriseId)
581 + fonction = try c.decodeIfPresent(String.self, forKey: .fonction) ?? ""
582 + emails = try c.decodeIfPresent([ContactValeurDto].self, forKey: .emails) ?? []
583 + telephones = try c.decodeIfPresent([ContactValeurDto].self, forKey: .telephones) ?? []
584 + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
585 + statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "prospect"
586 + etape = try c.decodeIfPresent(String.self, forKey: .etape) ?? "nouveau"
587 + tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
588 + }
589 +
590 + func encode(to encoder: Encoder) throws {
591 + var c = encoder.container(keyedBy: CodingKeys.self)
592 + try c.encode(prenom, forKey: .prenom)
593 + try c.encode(nom, forKey: .nom)
594 + try encodeExplicitNull(entrepriseId, in: &c, forKey: .entrepriseId)
595 + try c.encode(fonction, forKey: .fonction)
596 + try c.encode(emails, forKey: .emails)
597 + try c.encode(telephones, forKey: .telephones)
598 + try c.encode(notes, forKey: .notes)
599 + try c.encode(statut, forKey: .statut)
600 + try c.encode(etape, forKey: .etape)
601 + try c.encode(tags, forKey: .tags)
602 + }
603 +}
604 +
605 +/// Create/update RDV payload (mirror server, body of `/api/rdv`).
606 +struct RdvUpsertRequest: Codable, Equatable {
607 + var titre: String = ""
608 + var description: String = ""
609 + var lieu: String = ""
610 + var debut: String
611 + var fin: String
612 + var contactIds: [String] = []
613 + var projetId: String? = nil
614 +
615 + enum CodingKeys: String, CodingKey {
616 + case titre, description, lieu, debut, fin, contactIds, projetId
617 + }
618 +}
619 +
620 +extension RdvUpsertRequest {
621 + init(from decoder: Decoder) throws {
622 + let c = try decoder.container(keyedBy: CodingKeys.self)
623 + titre = try c.decodeIfPresent(String.self, forKey: .titre) ?? ""
624 + description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
625 + lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
626 + debut = try c.decode(String.self, forKey: .debut)
627 + fin = try c.decode(String.self, forKey: .fin)
628 + contactIds = try c.decodeIfPresent([String].self, forKey: .contactIds) ?? []
629 + projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
630 + }
631 +
632 + func encode(to encoder: Encoder) throws {
633 + var c = encoder.container(keyedBy: CodingKeys.self)
634 + try c.encode(titre, forKey: .titre)
635 + try c.encode(description, forKey: .description)
636 + try c.encode(lieu, forKey: .lieu)
637 + try c.encode(debut, forKey: .debut)
638 + try c.encode(fin, forKey: .fin)
639 + try c.encode(contactIds, forKey: .contactIds)
640 + try encodeExplicitNull(projetId, in: &c, forKey: .projetId)
641 + }
642 +}
643 +
644 +/// Create/update reservation payload (mirror server, body of `/api/ressources/:genre/:id/reservations`).
645 +struct ReservationUpsertRequest: Codable, Equatable {
646 + var debut: String
647 + var fin: String
648 + var motif: String = ""
649 + var projetId: String? = nil
650 + var rdvId: String? = nil
651 +
652 + enum CodingKeys: String, CodingKey {
653 + case debut, fin, motif, projetId, rdvId
654 + }
655 +}
656 +
657 +extension ReservationUpsertRequest {
658 + init(from decoder: Decoder) throws {
659 + let c = try decoder.container(keyedBy: CodingKeys.self)
660 + debut = try c.decode(String.self, forKey: .debut)
661 + fin = try c.decode(String.self, forKey: .fin)
662 + motif = try c.decodeIfPresent(String.self, forKey: .motif) ?? ""
663 + projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
664 + rdvId = try c.decodeIfPresent(String.self, forKey: .rdvId)
665 + }
666 +
667 + func encode(to encoder: Encoder) throws {
668 + var c = encoder.container(keyedBy: CodingKeys.self)
669 + try c.encode(debut, forKey: .debut)
670 + try c.encode(fin, forKey: .fin)
671 + try c.encode(motif, forKey: .motif)
672 + try encodeExplicitNull(projetId, in: &c, forKey: .projetId)
673 + try encodeExplicitNull(rdvId, in: &c, forKey: .rdvId)
674 + }
675 +}
676 +
677 +/// Encodes `nil` as an explicit JSON null (Kotlin `explicitNulls` default).
678 +private func encodeExplicitNull<K: CodingKey, T: Encodable>(
679 + _ value: T?,
680 + in container: inout KeyedEncodingContainer<K>,
681 + forKey key: K
682 +) throws {
683 + if let value {
684 + try container.encode(value, forKey: key)
685 + } else {
686 + try container.encodeNil(forKey: key)
687 + }
688 +}
A ios/Card2vcf/Sync/SyncTimeUtils.swift
+30 -0
@@ -0,0 +1,30 @@
1 +import Foundation
2 +
3 +/// ISO-8601 UTC → epoch ms, tolerant to absent/invalid values (mirror of server `mis_a_jour_le` timestamps).
4 +func parseIsoToEpochMs(_ iso: String?) -> Int64 {
5 + guard let iso else { return 0 }
6 + let withFraction = ISO8601DateFormatter()
7 + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
8 + if let date = withFraction.date(from: iso) {
9 + return Int64((date.timeIntervalSince1970 * 1000).rounded())
10 + }
11 + let plain = ISO8601DateFormatter()
12 + plain.formatOptions = [.withInternetDateTime]
13 + if let date = plain.date(from: iso) {
14 + return Int64((date.timeIntervalSince1970 * 1000).rounded())
15 + }
16 + return 0
17 +}
18 +
19 +/// epoch ms → ISO-8601 UTC, for payloads pushed to the server (`debut`/`fin`).
20 +/// Mirrors Kotlin `Instant.ofEpochMilli(ms).toString()`: no fraction when ms is whole seconds.
21 +func epochMsToIso(_ epochMs: Int64) -> String {
22 + let date = Date(timeIntervalSince1970: Double(epochMs) / 1000.0)
23 + let formatter = ISO8601DateFormatter()
24 + if epochMs % 1000 == 0 {
25 + formatter.formatOptions = [.withInternetDateTime]
26 + } else {
27 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
28 + }
29 + return formatter.string(from: date)
30 +}
A ios/Card2vcf/UI/Carnet/AlphabetRail.swift
+42 -0
@@ -0,0 +1,42 @@
1 +import SwiftUI
2 +
3 +// Port of ui/carnet/AlphabetRail.kt — A–Z + '#' rail; letters with contacts
4 +// are ink and tappable, the others muted.
5 +
6 +private let railLetters: [Character] = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ#")
7 +
8 +struct AlphabetRail: View {
9 + let presentLetters: Set<Character>
10 + let onLetter: (Character) -> Void
11 +
12 + var body: some View {
13 + GeometryReader { _ in
14 + VStack(alignment: .center, spacing: 0) {
15 + ForEach(railLetters, id: \.self) { letter in
16 + let present = presentLetters.contains(letter)
17 + Group {
18 + if present {
19 + Button {
20 + onLetter(letter)
21 + } label: {
22 + Text(String(letter))
23 + .font(C2VFont.labelSmall)
24 + .foregroundColor(C2VColor.ink)
25 + }
26 + .buttonStyle(.plain)
27 + } else {
28 + Text(String(letter))
29 + .font(C2VFont.labelSmall)
30 + .foregroundColor(C2VColor.body)
31 + }
32 + }
33 + .frame(maxHeight: .infinity)
34 + .padding(.vertical, 1)
35 + }
36 + }
37 + .frame(maxWidth: .infinity, maxHeight: .infinity)
38 + }
39 + .frame(width: 28)
40 + .padding(.vertical, 4)
41 + }
42 +}
A ios/Card2vcf/UI/Carnet/CarnetScreen.swift
+234 -0
@@ -0,0 +1,234 @@
1 +import SwiftUI
2 +
3 +// Port of ui/carnet/CarnetScreen.kt — header with sort menu + settings,
4 +// Carnet | Projets tabs, FTS search, sectioned list with alphabet rail,
5 +// square FAB stack (scan + add menu).
6 +
7 +struct CarnetScreen: View {
8 + @ObservedObject var viewModel: CarnetViewModel
9 + let onOpenContact: (Int64, ContactSort) -> Void
10 + let onScan: () -> Void
11 + let onManual: () -> Void
12 + let onImportVcf: () -> Void
13 + let onSettings: () -> Void
14 + let onOpenProjets: () -> Void
15 + @ObservedObject var syncViewModel: SyncChromeViewModel
16 +
17 + var body: some View {
18 + VStack(spacing: 0) {
19 + header
20 + MainTabRow(current: .carnet) { tab in
21 + if tab == .projets { onOpenProjets() }
22 + }
23 + C2VTextField(label: "", placeholder: "Rechercher…", text: Binding(
24 + get: { viewModel.query },
25 + set: { viewModel.setQuery($0) }
26 + ))
27 + SyncBanner(
28 + pendingChanges: syncViewModel.pendingRemoteChanges,
29 + localAhead: syncViewModel.localCalendarAhead,
30 + syncing: syncViewModel.syncing,
31 + error: syncViewModel.error,
32 + onSyncClick: { syncViewModel.syncNow() }
33 + )
34 + Spacer().frame(height: 8)
35 + content
36 + }
37 + .padding(.horizontal, 18)
38 + .background(C2VColor.fond)
39 + .overlay(alignment: .bottomTrailing) { fabStack }
40 + .syncConflictDialog(
41 + conflict: syncViewModel.conflictPending,
42 + onConfirm: { syncViewModel.confirmConflictCancellation() },
43 + onDismiss: { syncViewModel.dismissConflict() }
44 + )
45 + .toolbar(.hidden, for: .navigationBar)
46 + .task { await viewModel.refresh() }
47 + .onAppear { syncViewModel.refreshStatus() }
48 + }
49 +
50 + private var header: some View {
51 + HStack(spacing: 0) {
52 + Text("Card2vcf".uppercased())
53 + .font(C2VFont.titleLarge)
54 + .foregroundColor(C2VColor.ink)
55 + Spacer()
56 + if syncViewModel.configured {
57 + Button(action: { syncViewModel.syncNow() }) {
58 + Image(systemName: "arrow.triangle.2.circlepath")
59 + .foregroundColor(C2VColor.ink)
60 + .frame(width: 44, height: 44)
61 + }
62 + .accessibilityLabel("Synchroniser")
63 + }
64 + Menu {
65 + ForEach(ContactSort.allCases, id: \.self) { option in
66 + Button(sortLabel(option)) { viewModel.setSort(option) }
67 + }
68 + } label: {
69 + Text(sortLabel(viewModel.sort))
70 + .font(C2VFont.labelMedium)
71 + .foregroundColor(C2VColor.ink)
72 + .padding(.vertical, 8)
73 + .padding(.horizontal, 8)
74 + }
75 + Button(action: onSettings) {
76 + Image(systemName: "gearshape")
77 + .foregroundColor(C2VColor.ink)
78 + .frame(width: 44, height: 44)
79 + }
80 + .accessibilityLabel("Paramètres")
81 + }
82 + .padding(.top, 12)
83 + .padding(.bottom, 8)
84 + }
85 +
86 + @ViewBuilder
87 + private var content: some View {
88 + if viewModel.isEmpty {
89 + VStack {
90 + Spacer()
91 + Text("Aucun contact — scanner une carte ou importer un VCF")
92 + .font(C2VFont.bodyLarge)
93 + .foregroundColor(C2VColor.texteFaible)
94 + .multilineTextAlignment(.center)
95 + Spacer()
96 + }
97 + .frame(maxWidth: .infinity)
98 + } else {
99 + let showRail = viewModel.query.isBlank && viewModel.sort != .createdAt
100 + ScrollViewReader { proxy in
101 + HStack(spacing: 0) {
102 + contactList(showSectionHeaders: viewModel.sort != .createdAt)
103 + if showRail {
104 + AlphabetRail(presentLetters: viewModel.presentLetters) { letter in
105 + withAnimation {
106 + proxy.scrollTo("header-\(letter)", anchor: .top)
107 + }
108 + }
109 + }
110 + }
111 + }
112 + }
113 + }
114 +
115 + private func contactList(showSectionHeaders: Bool) -> some View {
116 + ScrollView {
117 + LazyVStack(alignment: .leading, spacing: 0) {
118 + ForEach(viewModel.sections, id: \.letter) { section in
119 + if showSectionHeaders {
120 + Text(String(section.letter))
121 + .font(C2VFont.labelMedium)
122 + .foregroundColor(C2VColor.texteFaible)
123 + .frame(maxWidth: .infinity, alignment: .leading)
124 + .padding(.vertical, 6)
125 + .background(C2VColor.fond)
126 + .id("header-\(section.letter)")
127 + }
128 + ForEach(section.contacts, id: \.id) { contact in
129 + ContactRow(
130 + contact: contact,
131 + duplicateCount: viewModel.duplicateCountById[contact.id] ?? 0
132 + ) {
133 + onOpenContact(contact.id, viewModel.sort)
134 + }
135 + }
136 + }
137 + }
138 + }
139 + }
140 +
141 + private var fabStack: some View {
142 + VStack(alignment: .trailing, spacing: 12) {
143 + Menu {
144 + Button("Saisie manuelle", action: onManual)
145 + Button("Import VCF", action: onImportVcf)
146 + } label: {
147 + Image(systemName: "plus")
148 + .foregroundColor(C2VColor.ink)
149 + .frame(width: 44, height: 44)
150 + .background(C2VColor.fond)
151 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
152 + }
153 + .accessibilityLabel("Autres ajouts")
154 + Button(action: onScan) {
155 + Image(systemName: "camera.fill")
156 + .foregroundColor(C2VColor.onPrimary)
157 + .frame(width: 56, height: 56)
158 + .background(C2VColor.ink)
159 + }
160 + .accessibilityLabel("Scanner une carte")
161 + }
162 + .padding(.trailing, 18)
163 + .padding(.bottom, 24)
164 + }
165 +}
166 +
167 +private struct ContactRow: View {
168 + let contact: CrmContactEntity
169 + let duplicateCount: Int
170 + let onTap: () -> Void
171 +
172 + var body: some View {
173 + Button(action: onTap) {
174 + VStack(spacing: 0) {
175 + HStack(spacing: 10) {
176 + thumbnail
177 + VStack(alignment: .leading, spacing: 2) {
178 + Text(contactDisplayName(contact))
179 + .font(C2VFont.bodyLarge)
180 + .foregroundColor(C2VColor.ink)
181 + if let subtitle = contactSubtitle(contact) {
182 + Text(subtitle)
183 + .font(C2VFont.bodyMedium)
184 + .foregroundColor(C2VColor.texteFaible)
185 + }
186 + }
187 + Spacer()
188 + if duplicateCount > 0 {
189 + Text("\(duplicateCount)")
190 + .font(C2VFont.labelSmall)
191 + .foregroundColor(C2VColor.texteFaible)
192 + .padding(4)
193 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
194 + .accessibilityLabel("\(duplicateCount) doublon(s)")
195 + }
196 + }
197 + .padding(.vertical, 10)
198 + C2VDivider()
199 + }
200 + }
201 + .buttonStyle(.plain)
202 + }
203 +
204 + @ViewBuilder
205 + private var thumbnail: some View {
206 + if let path = contact.profileImagePath?.nilIfBlank, let ui = UIImage(contentsOfFile: path) {
207 + Image(uiImage: ui)
208 + .resizable()
209 + .aspectRatio(contentMode: .fill)
210 + .frame(width: 40, height: 40)
211 + .clipped()
212 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
213 + } else {
214 + Text(String(contactDisplayName(contact).prefix(1)).uppercased())
215 + .font(C2VFont.labelMedium)
216 + .foregroundColor(C2VColor.texteFaible)
217 + .frame(width: 40, height: 40)
218 + .background(C2VColor.canvasSoft)
219 + }
220 + }
221 +}
222 +
223 +extension ContactSort: CaseIterable {
224 + static var allCases: [ContactSort] { [.lastName, .firstName, .company, .createdAt] }
225 +}
226 +
227 +func sortLabel(_ sort: ContactSort) -> String {
228 + switch sort {
229 + case .lastName: return "Nom"
230 + case .firstName: return "Prénom"
231 + case .company: return "Entreprise"
232 + case .createdAt: return "Date"
233 + }
234 +}
A ios/Card2vcf/UI/Carnet/CarnetViewModel.swift
+78 -0
@@ -0,0 +1,78 @@
1 +import Combine
2 +import Foundation
3 +
4 +// Port of ui/carnet/CarnetViewModel.kt. Kotlin observed Room flows; on iOS the
5 +// DAOs are fetch-based, so the view model refreshes on demand and on the
6 +// database-change notification posted by the persistence layer.
7 +
8 +@MainActor
9 +final class CarnetViewModel: ObservableObject {
10 + @Published private(set) var sort: ContactSort = .lastName
11 + @Published private(set) var query: String = ""
12 + @Published private(set) var sections: [AlphabetIndex.Section] = []
13 + @Published private(set) var duplicateCountById: [Int64: Int] = [:]
14 +
15 + let repository: ContactRepository
16 +
17 + private var cancellables: Set<AnyCancellable> = []
18 + private var refreshTask: Task<Void, Never>?
19 +
20 + init(repository: ContactRepository = ContactRepository(
21 + dao: Card2vcfDatabase.shared.crmContactDao,
22 + images: ContactImageStore()
23 + )) {
24 + self.repository = repository
25 + NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
26 + .receive(on: RunLoop.main)
27 + .sink { [weak self] _ in self?.scheduleRefresh() }
28 + .store(in: &cancellables)
29 + }
30 +
31 + var isEmpty: Bool {
32 + sections.isEmpty || sections.allSatisfy { $0.contacts.isEmpty }
33 + }
34 +
35 + var presentLetters: Set<Character> {
36 + Set(sections.map { $0.letter })
37 + }
38 +
39 + func setQuery(_ value: String) {
40 + query = value
41 + scheduleRefresh()
42 + }
43 +
44 + func setSort(_ value: ContactSort) {
45 + sort = value
46 + scheduleRefresh()
47 + }
48 +
49 + func scheduleRefresh() {
50 + refreshTask?.cancel()
51 + refreshTask = Task { await refresh() }
52 + }
53 +
54 + func refresh() async {
55 + do {
56 + let all = try await repository.fetchAll()
57 + let list: [CrmContactEntity]
58 + if query.isBlank {
59 + list = all
60 + } else {
61 + list = try await repository.search(query)
62 + }
63 + let exclusions = (try? await repository.getExclusions()) ?? []
64 + let clusters = DuplicateDetector.clusters(contacts: all, exclusions: exclusions)
65 + guard !Task.isCancelled else { return }
66 + sections = AlphabetIndex.group(contacts: list, sort: sort)
67 + duplicateCountById = Dictionary(
68 + uniqueKeysWithValues: all.map {
69 + ($0.id, DuplicateDetector.duplicateCount($0.id, clusters: clusters))
70 + }
71 + )
72 + } catch {
73 + guard !Task.isCancelled else { return }
74 + sections = []
75 + duplicateCountById = [:]
76 + }
77 + }
78 +}
A ios/Card2vcf/UI/Contact/ContactEditScreen.swift
+145 -0
@@ -0,0 +1,145 @@
1 +import SwiftUI
2 +
3 +// Port of ui/contact/ContactEditScreen.kt (+ ManualContactScreen).
4 +
5 +struct ContactEditScreen: View {
6 + @StateObject private var viewModel: ContactEditViewModel
7 + let onBack: () -> Void
8 + let onSaved: () -> Void
9 + let onRescan: (Int64) -> Void
10 +
11 + @State private var showCamera = false
12 + @State private var showPhotoPicker = false
13 +
14 + init(
15 + contactId: Int64,
16 + onBack: @escaping () -> Void,
17 + onSaved: @escaping () -> Void,
18 + onRescan: @escaping (Int64) -> Void
19 + ) {
20 + _viewModel = StateObject(wrappedValue: ContactEditViewModel(contactId: contactId))
21 + self.onBack = onBack
22 + self.onSaved = onSaved
23 + self.onRescan = onRescan
24 + }
25 +
26 + var body: some View {
27 + VStack(spacing: 0) {
28 + ScreenTopBar(title: "Modifier le contact", centered: false, onBack: onBack)
29 + if !viewModel.loaded {
30 + Spacer()
31 + ProgressView().tint(C2VColor.ink)
32 + Spacer()
33 + } else {
34 + ScrollView {
35 + VStack(alignment: .leading, spacing: 14) {
36 + Text("Photo de profil")
37 + .font(C2VFont.bodyMedium)
38 + .foregroundColor(C2VColor.encre)
39 + profilePreview
40 + HStack(spacing: 10) {
41 + Button("Prendre une photo") { showCamera = true }
42 + .buttonStyle(C2VOutlineButtonStyle())
43 + .disabled(!CameraPickerView.isAvailable)
44 + Button("Choisir une photo") { showPhotoPicker = true }
45 + .buttonStyle(C2VOutlineButtonStyle())
46 + }
47 + ContactDraftFieldsView(card: $viewModel.card, phonesAsList: true)
48 + Button("Rescanner la carte") { onRescan(viewModel.contactId) }
49 + .buttonStyle(C2VOutlineButtonStyle())
50 + }
51 + .padding(.horizontal, 18)
52 + .padding(.vertical, 12)
53 + }
54 + Button("Enregistrer") { viewModel.save() }
55 + .buttonStyle(C2VPrimaryButtonStyle())
56 + .padding(.horizontal, 18)
57 + .padding(.vertical, 12)
58 + }
59 + }
60 + .background(C2VColor.fond)
61 + .toolbar(.hidden, for: .navigationBar)
62 + .task { await viewModel.load() }
63 + .onChange(of: viewModel.saveDone) { done in
64 + if done { onSaved() }
65 + }
66 + .fullScreenCover(isPresented: $showCamera) {
67 + CameraPickerView { image in
68 + showCamera = false
69 + if let image { viewModel.setProfileImage(ImageOrientation.orient(image).bitmap) }
70 + }
71 + .ignoresSafeArea()
72 + }
73 + .sheet(isPresented: $showPhotoPicker) {
74 + PhotoPickerView { image in
75 + showPhotoPicker = false
76 + if let image { viewModel.setProfileImage(ImageOrientation.orient(image).bitmap) }
77 + }
78 + }
79 + }
80 +
81 + @ViewBuilder
82 + private var profilePreview: some View {
83 + if let preview = viewModel.profilePreview {
84 + Image(uiImage: preview)
85 + .resizable()
86 + .aspectRatio(contentMode: .fill)
87 + .frame(width: 96, height: 96)
88 + .clipped()
89 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
90 + } else {
91 + Rectangle()
92 + .fill(C2VColor.surface)
93 + .frame(width: 96, height: 96)
94 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
95 + }
96 + }
97 +}
98 +
99 +/// Kotlin `ManualContactScreen` — blank draft saved straight to the carnet.
100 +struct ManualContactScreen: View {
101 + let onBack: () -> Void
102 + let onSaved: (Int64) -> Void
103 +
104 + @State private var card = ContactCard()
105 + @State private var saving = false
106 +
107 + private let repository = ContactRepository(
108 + dao: Card2vcfDatabase.shared.crmContactDao,
109 + images: ContactImageStore()
110 + )
111 +
112 + var body: some View {
113 + VStack(spacing: 0) {
114 + ScreenTopBar(title: "Nouveau contact", centered: false, onBack: onBack)
115 + ScrollView {
116 + VStack(alignment: .leading, spacing: 14) {
117 + Text("Saisissez les champs, puis enregistrez dans le carnet.")
118 + .font(C2VFont.bodyMedium)
119 + .foregroundColor(C2VColor.texteFaible)
120 + ContactDraftFieldsView(card: $card)
121 + Spacer().frame(height: 8)
122 + }
123 + .padding(.horizontal, 18)
124 + .padding(.vertical, 12)
125 + }
126 + Button("Enregistrer") {
127 + guard !saving else { return }
128 + saving = true
129 + Task {
130 + do {
131 + let id = try await repository.insertFromCard(card)
132 + onSaved(id)
133 + } catch {
134 + saving = false
135 + }
136 + }
137 + }
138 + .buttonStyle(C2VPrimaryButtonStyle())
139 + .padding(.horizontal, 18)
140 + .padding(.vertical, 12)
141 + }
142 + .background(C2VColor.fond)
143 + .toolbar(.hidden, for: .navigationBar)
144 + }
145 +}
A ios/Card2vcf/UI/Contact/ContactEditViewModel.swift
+56 -0
@@ -0,0 +1,56 @@
1 +import Foundation
2 +import UIKit
3 +
4 +// Port of ui/contact/ContactEditViewModel.kt.
5 +
6 +@MainActor
7 +final class ContactEditViewModel: ObservableObject {
8 + @Published var card = ContactCard()
9 + @Published private(set) var profilePreview: UIImage?
10 + @Published private(set) var loaded = false
11 + @Published private(set) var saveDone = false
12 +
13 + let contactId: Int64
14 +
15 + private let repository: ContactRepository
16 + private var pendingProfile: UIImage?
17 +
18 + init(
19 + contactId: Int64,
20 + repository: ContactRepository = ContactRepository(
21 + dao: Card2vcfDatabase.shared.crmContactDao,
22 + images: ContactImageStore()
23 + )
24 + ) {
25 + self.contactId = contactId
26 + self.repository = repository
27 + }
28 +
29 + func load() async {
30 + guard !loaded else { return }
31 + guard let entity = try? await repository.getById(contactId) else { return }
32 + card = entity.toCard()
33 + if let path = entity.profileImagePath?.nilIfBlank {
34 + profilePreview = UIImage(contentsOfFile: path)
35 + }
36 + loaded = true
37 + }
38 +
39 + func setProfileImage(_ image: UIImage) {
40 + pendingProfile = image
41 + profilePreview = image
42 + }
43 +
44 + func save() {
45 + Task {
46 + let profilePath = pendingProfile.flatMap { DraftImageWriter.writeJpeg($0, prefix: "profile") }
47 + try? await repository.updateFromCard(
48 + id: contactId,
49 + card: card,
50 + profileImagePath: profilePath,
51 + preserveNotesIfBlankDraft: false
52 + )
53 + saveDone = true
54 + }
55 + }
56 +}
A ios/Card2vcf/UI/Contact/ContactPagerScreen.swift
+264 -0
@@ -0,0 +1,264 @@
1 +import SwiftUI
2 +
3 +// Port of ui/contact/ContactPagerScreen.kt — swipeable pager of contact
4 +// fiches. Adds the iOS actions: export VCF, system contact, delete with
5 +// confirmation, inline notes editor.
6 +
7 +struct ContactPagerScreen: View {
8 + @StateObject private var viewModel: ContactPagerViewModel
9 + let onBack: () -> Void
10 + let onEdit: (Int64) -> Void
11 + let onDuplicates: (Int64) -> Void
12 + let onDeleted: () -> Void
13 +
14 + @State private var pageIndex: Int = 0
15 + @State private var initialized = false
16 +
17 + init(
18 + contactId: Int64,
19 + sort: ContactSort,
20 + onBack: @escaping () -> Void,
21 + onEdit: @escaping (Int64) -> Void,
22 + onDuplicates: @escaping (Int64) -> Void,
23 + onDeleted: @escaping () -> Void
24 + ) {
25 + _viewModel = StateObject(
26 + wrappedValue: ContactPagerViewModel(initialContactId: contactId, sort: sort)
27 + )
28 + self.onBack = onBack
29 + self.onEdit = onEdit
30 + self.onDuplicates = onDuplicates
31 + self.onDeleted = onDeleted
32 + }
33 +
34 + var body: some View {
35 + VStack(spacing: 0) {
36 + if viewModel.contacts.isEmpty {
37 + topBar(current: nil)
38 + Spacer()
39 + Text("Aucun contact — scanner une carte ou importer un VCF")
40 + .font(C2VFont.bodyMedium)
41 + .foregroundColor(C2VColor.texteFaible)
42 + Spacer()
43 + } else {
44 + let current = viewModel.contacts.indices.contains(pageIndex)
45 + ? viewModel.contacts[pageIndex]
46 + : viewModel.contacts[0]
47 + topBar(current: current)
48 + TabView(selection: $pageIndex) {
49 + ForEach(Array(viewModel.contacts.enumerated()), id: \.element.id) { index, contact in
50 + ContactFichePage(
51 + contact: contact,
52 + onSaveNotes: { notes in
53 + Task { await viewModel.saveNotes(contactId: contact.id, notes: notes) }
54 + },
55 + onDelete: {
56 + Task {
57 + await viewModel.delete(contactId: contact.id)
58 + onDeleted()
59 + }
60 + }
61 + )
62 + .tag(index)
63 + }
64 + }
65 + .tabViewStyle(.page(indexDisplayMode: .never))
66 + }
67 + }
68 + .background(C2VColor.fond)
69 + .toolbar(.hidden, for: .navigationBar)
70 + .task {
71 + await viewModel.refresh()
72 + if !initialized {
73 + pageIndex = viewModel.initialPageIndex(viewModel.contacts)
74 + initialized = true
75 + }
76 + }
77 + }
78 +
79 + @ViewBuilder
80 + private func topBar(current: CrmContactEntity?) -> some View {
81 + HStack(spacing: 0) {
82 + Button(action: onBack) {
83 + Image(systemName: "arrow.left")
84 + .foregroundColor(C2VColor.ink)
85 + .frame(width: 44, height: 44)
86 + }
87 + .accessibilityLabel("Retour")
88 + Spacer()
89 + if let current {
90 + let dupCount = viewModel.duplicateCountById[current.id] ?? 0
91 + if dupCount > 0 {
92 + Button(String(format: "%d doublon(s)", dupCount)) {
93 + onDuplicates(current.id)
94 + }
95 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.encre))
96 + }
97 + Button("Éditer") { onEdit(current.id) }
98 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.encre))
99 + }
100 + }
101 + .padding(.horizontal, 4)
102 + .padding(.vertical, 4)
103 + .background(C2VColor.surface)
104 + }
105 +}
106 +
107 +private struct ContactFichePage: View {
108 + let contact: CrmContactEntity
109 + let onSaveNotes: (String) -> Void
110 + let onDelete: () -> Void
111 +
112 + @State private var notesDraft: String
113 + @State private var showSystemContact = false
114 + @State private var confirmDelete = false
115 +
116 + init(
117 + contact: CrmContactEntity,
118 + onSaveNotes: @escaping (String) -> Void,
119 + onDelete: @escaping () -> Void
120 + ) {
121 + self.contact = contact
122 + self.onSaveNotes = onSaveNotes
123 + self.onDelete = onDelete
124 + _notesDraft = State(initialValue: contact.notes ?? "")
125 + }
126 +
127 + var body: some View {
128 + ScrollView {
129 + VStack(alignment: .leading, spacing: 12) {
130 + PathImage(path: contact.profileImagePath, revision: contact.updatedAt)
131 + .frame(width: 96, height: 96)
132 + .clipped()
133 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
134 +
135 + Text(contactDisplayName(contact))
136 + .font(C2VFont.titleLarge)
137 + .foregroundColor(C2VColor.encre)
138 +
139 + fieldBlock("Prénom", contact.firstName)
140 + fieldBlock("Nom de famille", contact.lastName)
141 + fieldBlock("Société", contact.company)
142 + fieldBlock("Poste", contact.jobTitle)
143 + clickableFieldBlock("Téléphone(s)", values: phones) { ExternalLinks.dial($0) }
144 + clickableFieldBlock("E-mail(s)", values: emails) { ExternalLinks.email($0) }
145 + clickableFieldBlock(
146 + "Site web",
147 + values: [contact.website?.nilIfBlank].compactMap { $0 }
148 + ) { ExternalLinks.web($0) }
149 + fieldBlock("Adresse", contact.address)
150 +
151 + if let cardPath = contact.cardImagePath?.nilIfBlank {
152 + C2VDivider()
153 + PathImage(path: cardPath, contentMode: .fit, revision: contact.updatedAt)
154 + .frame(maxWidth: .infinity)
155 + .frame(height: 220)
156 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
157 + }
158 +
159 + C2VDivider()
160 + notesEditor
161 +
162 + C2VDivider()
163 + actions
164 + }
165 + .padding(.horizontal, 18)
166 + .padding(.vertical, 12)
167 + }
168 + .sheet(isPresented: $showSystemContact) {
169 + SystemContactView(card: contact.toCard()) {
170 + showSystemContact = false
171 + }
172 + }
173 + .confirmationDialog(
174 + "Supprimer ce contact ?",
175 + isPresented: $confirmDelete,
176 + titleVisibility: .visible
177 + ) {
178 + Button("Supprimer", role: .destructive, action: onDelete)
179 + Button("Annuler", role: .cancel) {}
180 + }
181 + }
182 +
183 + private var phones: [String] { contact.phones.filter { $0.isNotBlank } }
184 + private var emails: [String] { contact.emails.filter { $0.isNotBlank } }
185 +
186 + private var notesEditor: some View {
187 + VStack(alignment: .leading, spacing: 4) {
188 + Text("Note")
189 + .font(C2VFont.labelMedium)
190 + .foregroundColor(C2VColor.texteFaible)
191 + TextEditor(text: $notesDraft)
192 + .font(C2VFont.bodyLarge)
193 + .foregroundColor(C2VColor.encre)
194 + .tint(C2VColor.ink)
195 + .frame(minHeight: 72)
196 + .scrollContentBackground(.hidden)
197 + .background(C2VColor.canvas)
198 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
199 + if notesDraft != (contact.notes ?? "") {
200 + Button("Enregistrer") { onSaveNotes(notesDraft) }
201 + .buttonStyle(C2VTextButtonStyle())
202 + }
203 + }
204 + }
205 +
206 + private var actions: some View {
207 + VStack(spacing: 10) {
208 + ShareLink(
209 + item: VcfShareFile(card: contact.toCard()),
210 + preview: SharePreview(VcfShare.fileName(contact.toCard()))
211 + ) {
212 + Text("Exporter VCF")
213 + }
214 + .buttonStyle(C2VOutlineButtonStyle())
215 + Button("Créer le contact") { showSystemContact = true }
216 + .buttonStyle(C2VOutlineButtonStyle())
217 + Button("Supprimer") { confirmDelete = true }
218 + .buttonStyle(C2VOutlineButtonStyle())
219 + }
220 + }
221 +
222 + @ViewBuilder
223 + private func fieldBlock(_ label: String, _ value: String?) -> some View {
224 + if let value = value?.nilIfBlank {
225 + VStack(alignment: .leading, spacing: 2) {
226 + Text(label)
227 + .font(C2VFont.labelMedium)
228 + .foregroundColor(C2VColor.texteFaible)
229 + Text(value)
230 + .font(C2VFont.bodyLarge)
231 + .foregroundColor(C2VColor.encre)
232 + }
233 + .frame(maxWidth: .infinity, alignment: .leading)
234 + }
235 + }
236 +
237 + @ViewBuilder
238 + private func clickableFieldBlock(
239 + _ label: String,
240 + values: [String],
241 + onTap: @escaping (String) -> Void
242 + ) -> some View {
243 + if !values.isEmpty {
244 + VStack(alignment: .leading, spacing: 2) {
245 + Text(label)
246 + .font(C2VFont.labelMedium)
247 + .foregroundColor(C2VColor.texteFaible)
248 + ForEach(values, id: \.self) { value in
249 + Button {
250 + onTap(value)
251 + } label: {
252 + Text(value)
253 + .font(C2VFont.bodyLarge)
254 + .foregroundColor(C2VColor.link)
255 + .frame(maxWidth: .infinity, alignment: .leading)
256 + .padding(.vertical, 4)
257 + }
258 + .buttonStyle(.plain)
259 + }
260 + }
261 + .frame(maxWidth: .infinity, alignment: .leading)
262 + }
263 + }
264 +}
A ios/Card2vcf/UI/Contact/ContactPagerViewModel.swift
+75 -0
@@ -0,0 +1,75 @@
1 +import Combine
2 +import Foundation
3 +
4 +// Port of ui/contact/ContactPagerViewModel.kt — ordered contacts for the
5 +// pager (same order as the carnet for the given sort) + duplicate counts.
6 +// Adds delete and notes-save actions surfaced by the iOS fiche.
7 +
8 +@MainActor
9 +final class ContactPagerViewModel: ObservableObject {
10 + @Published private(set) var contacts: [CrmContactEntity] = []
11 + @Published private(set) var duplicateCountById: [Int64: Int] = [:]
12 +
13 + let initialContactId: Int64
14 + let sort: ContactSort
15 +
16 + private let repository: ContactRepository
17 + private var cancellables: Set<AnyCancellable> = []
18 +
19 + init(
20 + initialContactId: Int64,
21 + sort: ContactSort,
22 + repository: ContactRepository = ContactRepository(
23 + dao: Card2vcfDatabase.shared.crmContactDao,
24 + images: ContactImageStore()
25 + )
26 + ) {
27 + self.initialContactId = initialContactId
28 + self.sort = sort
29 + self.repository = repository
30 + NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
31 + .receive(on: RunLoop.main)
32 + .sink { [weak self] _ in Task { await self?.refresh() } }
33 + .store(in: &cancellables)
34 + }
35 +
36 + func refresh() async {
37 + do {
38 + let list = try await repository.fetchAll()
39 + let exclusions = (try? await repository.getExclusions()) ?? []
40 + let clusters = DuplicateDetector.clusters(contacts: list, exclusions: exclusions)
41 + contacts = AlphabetIndex.group(contacts: list, sort: sort).flatMap { $0.contacts }
42 + duplicateCountById = Dictionary(
43 + uniqueKeysWithValues: list.map {
44 + ($0.id, DuplicateDetector.duplicateCount($0.id, clusters: clusters))
45 + }
46 + )
47 + } catch {
48 + contacts = []
49 + duplicateCountById = [:]
50 + }
51 + }
52 +
53 + func initialPageIndex(_ list: [CrmContactEntity]) -> Int {
54 + let idx = list.firstIndex { $0.id == initialContactId }
55 + return idx ?? 0
56 + }
57 +
58 + /// Inline notes editor on the fiche (Android edited notes in the edit form).
59 + func saveNotes(contactId: Int64, notes: String) async {
60 + guard let entity = try? await repository.getById(contactId) else { return }
61 + var card = entity.toCard()
62 + card.note = notes.nilIfBlank
63 + try? await repository.updateFromCard(
64 + id: contactId,
65 + card: card,
66 + preserveNotesIfBlankDraft: false
67 + )
68 + await refresh()
69 + }
70 +
71 + func delete(contactId: Int64) async {
72 + try? await repository.delete(contactId)
73 + await refresh()
74 + }
75 +}
A ios/Card2vcf/UI/Contact/DuplicateReviewScreen.swift
+346 -0
@@ -0,0 +1,346 @@
1 +import SwiftUI
2 +
3 +// Port of ui/contact/DuplicateReviewScreen.kt — cluster review (select,
4 +// delete, mark different, merge) + field-by-field merge panel.
5 +
6 +struct DuplicateReviewScreen: View {
7 + @StateObject private var viewModel: DuplicateReviewViewModel
8 + let onBack: () -> Void
9 + let onMerged: (Int64) -> Void
10 +
11 + init(
12 + contactId: Int64,
13 + onBack: @escaping () -> Void,
14 + onMerged: @escaping (Int64) -> Void
15 + ) {
16 + _viewModel = StateObject(wrappedValue: DuplicateReviewViewModel(contactId: contactId))
17 + self.onBack = onBack
18 + self.onMerged = onMerged
19 + }
20 +
21 + var body: some View {
22 + VStack(spacing: 0) {
23 + ScreenTopBar(
24 + title: viewModel.merging ? "Fusionner" : "Doublons",
25 + centered: false,
26 + onBack: {
27 + if viewModel.merging {
28 + viewModel.cancelMerge()
29 + } else {
30 + onBack()
31 + }
32 + }
33 + )
34 + content
35 + }
36 + .background(C2VColor.fond)
37 + .toolbar(.hidden, for: .navigationBar)
38 + .task { await viewModel.refresh() }
39 + .onChange(of: viewModel.mergeDoneKeepId) { keepId in
40 + if let keepId { onMerged(keepId) }
41 + }
42 + }
43 +
44 + @ViewBuilder
45 + private var content: some View {
46 + let cluster = viewModel.cluster
47 + let primary = cluster.first { $0.id == viewModel.contactId }
48 + let others = cluster.filter { $0.id != viewModel.contactId }
49 +
50 + if viewModel.merging {
51 + let sources = viewModel.mergeSources(cluster)
52 + if sources.count < 2 {
53 + emptyState
54 + } else {
55 + MergePanel(
56 + sources: sources,
57 + initial: viewModel.defaultChoices(sources),
58 + onConfirm: { choices in
59 + viewModel.confirmMerge(sources: sources, choices: choices)
60 + },
61 + onCancel: { viewModel.cancelMerge() }
62 + )
63 + }
64 + } else if others.isEmpty {
65 + emptyState
66 + } else {
67 + ScrollView {
68 + VStack(alignment: .leading, spacing: 12) {
69 + if let primary {
70 + Text("Contact courant")
71 + .font(C2VFont.labelMedium)
72 + .foregroundColor(C2VColor.texteFaible)
73 + ContactSummaryRow(contact: primary, selected: false, onToggle: nil)
74 + C2VDivider()
75 + }
76 + Text("Contacts similaires")
77 + .font(C2VFont.labelMedium)
78 + .foregroundColor(C2VColor.texteFaible)
79 + ForEach(others, id: \.id) { c in
80 + ContactSummaryRow(
81 + contact: c,
82 + selected: viewModel.selectedIds.contains(c.id),
83 + onToggle: { viewModel.toggleSelect(c.id) }
84 + )
85 + }
86 + }
87 + .padding(.horizontal, 18)
88 + .padding(.vertical, 12)
89 + }
90 + VStack(spacing: 8) {
91 + let hasSelection = !viewModel.selectedIds.isEmpty
92 + Button("Supprimer la sélection") { viewModel.deleteSelected() }
93 + .buttonStyle(C2VOutlineButtonStyle())
94 + .disabled(!hasSelection)
95 + Button("Contacts différents") { viewModel.markSelectedDifferent() }
96 + .buttonStyle(C2VOutlineButtonStyle())
97 + .disabled(!hasSelection)
98 + Button("Fusionner") { viewModel.startMerge() }
99 + .buttonStyle(C2VPrimaryButtonStyle())
100 + }
101 + .padding(.horizontal, 18)
102 + .padding(.vertical, 12)
103 + }
104 + }
105 +
106 + private var emptyState: some View {
107 + VStack {
108 + Spacer()
109 + Text("Aucun doublon")
110 + .font(C2VFont.bodyMedium)
111 + .foregroundColor(C2VColor.texteFaible)
112 + Spacer()
113 + }
114 + .frame(maxWidth: .infinity)
115 + }
116 +}
117 +
118 +private struct ContactSummaryRow: View {
119 + let contact: CrmContactEntity
120 + let selected: Bool
121 + let onToggle: (() -> Void)?
122 +
123 + var body: some View {
124 + let row = HStack(spacing: 10) {
125 + if onToggle != nil {
126 + Image(systemName: selected ? "checkmark.square.fill" : "square")
127 + .foregroundColor(C2VColor.ink)
128 + }
129 + VStack(alignment: .leading, spacing: 2) {
130 + Text(mergeDisplayName(contact))
131 + .font(C2VFont.bodyLarge)
132 + .foregroundColor(C2VColor.encre)
133 + let detail = [
134 + contact.emails.first?.nilIfBlank,
135 + contact.phones.first?.nilIfBlank,
136 + contact.company?.nilIfBlank
137 + ]
138 + .compactMap { $0 }
139 + .joined(separator: " · ")
140 + if !detail.isEmpty {
141 + Text(detail)
142 + .font(C2VFont.bodySmall)
143 + .foregroundColor(C2VColor.texteFaible)
144 + }
145 + }
146 + Spacer(minLength: 0)
147 + }
148 + .padding(12)
149 + .frame(maxWidth: .infinity, alignment: .leading)
150 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
151 +
152 + if let onToggle {
153 + Button(action: onToggle) { row }
154 + .buttonStyle(.plain)
155 + } else {
156 + row
157 + }
158 + }
159 +}
160 +
161 +private struct MergePanel: View {
162 + let sources: [CrmContactEntity]
163 + let onConfirm: (MergeFieldChoices) -> Void
164 + let onCancel: () -> Void
165 +
166 + @State private var choices: MergeFieldChoices
167 +
168 + init(
169 + sources: [CrmContactEntity],
170 + initial: MergeFieldChoices,
171 + onConfirm: @escaping (MergeFieldChoices) -> Void,
172 + onCancel: @escaping () -> Void
173 + ) {
174 + self.sources = sources
175 + self.onConfirm = onConfirm
176 + self.onCancel = onCancel
177 + _choices = State(initialValue: initial)
178 + }
179 +
180 + var body: some View {
181 + ScrollView {
182 + VStack(alignment: .leading, spacing: 14) {
183 + Text("Conserver la fiche")
184 + .font(C2VFont.bodyMedium)
185 + .foregroundColor(C2VColor.texteFaible)
186 + ForEach(sources, id: \.id) { c in
187 + RadioRow(label: mergeDisplayName(c), selected: choices.keepId == c.id) {
188 + choices.keepId = c.id
189 + }
190 + }
191 +
192 + C2VDivider()
193 +
194 + fieldPicker("Nom complet", selectedId: choices.fullNameFromId, valueOf: { $0.fullName }) {
195 + choices.fullNameFromId = $0
196 + }
197 + fieldPicker("Prénom", selectedId: choices.firstNameFromId, valueOf: { $0.firstName }) {
198 + choices.firstNameFromId = $0
199 + }
200 + fieldPicker("Nom de famille", selectedId: choices.lastNameFromId, valueOf: { $0.lastName }) {
201 + choices.lastNameFromId = $0
202 + }
203 + fieldPicker("Société", selectedId: choices.companyFromId, valueOf: { $0.company }) {
204 + choices.companyFromId = $0
205 + }
206 + fieldPicker("Poste", selectedId: choices.jobTitleFromId, valueOf: { $0.jobTitle }) {
207 + choices.jobTitleFromId = $0
208 + }
209 + fieldPicker("Site web", selectedId: choices.websiteFromId, valueOf: { $0.website }) {
210 + choices.websiteFromId = $0
211 + }
212 + fieldPicker("Adresse", selectedId: choices.addressFromId, valueOf: { $0.address }) {
213 + choices.addressFromId = $0
214 + }
215 +
216 + Text("Notes")
217 + .font(C2VFont.bodyMedium)
218 + .foregroundColor(C2VColor.texteFaible)
219 + RadioRow(
220 + label: "Garder celles de la fiche conservée",
221 + selected: choices.notesMode == .keepPrimary
222 + ) {
223 + choices.notesMode = .keepPrimary
224 + }
225 + RadioRow(label: "Concaténer toutes les notes", selected: choices.notesMode == .concat) {
226 + choices.notesMode = .concat
227 + }
228 +
229 + imagePicker(
230 + "Photo de la carte",
231 + selectedId: choices.cardImageFromId,
232 + pathOf: { $0.cardImagePath }
233 + ) {
234 + choices.cardImageFromId = $0
235 + }
236 + imagePicker(
237 + "Photo de profil",
238 + selectedId: choices.profileImageFromId,
239 + pathOf: { $0.profileImagePath }
240 + ) {
241 + choices.profileImageFromId = $0
242 + }
243 +
244 + Spacer().frame(height: 8)
245 + Button("Annuler", action: onCancel)
246 + .buttonStyle(C2VOutlineButtonStyle())
247 + Button("Confirmer la fusion") { onConfirm(choices) }
248 + .buttonStyle(C2VPrimaryButtonStyle())
249 + }
250 + .padding(.horizontal, 18)
251 + .padding(.vertical, 12)
252 + }
253 + }
254 +
255 + private func fieldPicker(
256 + _ label: String,
257 + selectedId: Int64,
258 + valueOf: @escaping (CrmContactEntity) -> String?,
259 + onSelect: @escaping (Int64) -> Void
260 + ) -> some View {
261 + VStack(alignment: .leading, spacing: 4) {
262 + Text(label)
263 + .font(C2VFont.labelMedium)
264 + .foregroundColor(C2VColor.texteFaible)
265 + ForEach(sources, id: \.id) { c in
266 + let value = valueOf(c)?.nilIfBlank ?? "—"
267 + RadioRow(label: "\(mergeDisplayName(c)): \(value)", selected: selectedId == c.id) {
268 + onSelect(c.id)
269 + }
270 + }
271 + }
272 + }
273 +
274 + @ViewBuilder
275 + private func imagePicker(
276 + _ label: String,
277 + selectedId: Int64?,
278 + pathOf: @escaping (CrmContactEntity) -> String?,
279 + onSelect: @escaping (Int64?) -> Void
280 + ) -> some View {
281 + let withImage = sources.filter { pathOf($0)?.nilIfBlank != nil }
282 + if !withImage.isEmpty {
283 + VStack(alignment: .leading, spacing: 6) {
284 + Text(label)
285 + .font(C2VFont.labelMedium)
286 + .foregroundColor(C2VColor.texteFaible)
287 + RadioRow(label: "Aucune", selected: selectedId == nil) {
288 + onSelect(nil)
289 + }
290 + ForEach(withImage, id: \.id) { c in
291 + Button {
292 + onSelect(c.id)
293 + } label: {
294 + HStack(spacing: 10) {
295 + Image(systemName: selectedId == c.id ? "circle.inset.filled" : "circle")
296 + .foregroundColor(C2VColor.ink)
297 + PathImage(path: pathOf(c))
298 + .frame(width: 48, height: 48)
299 + .clipped()
300 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
301 + Text(mergeDisplayName(c))
302 + .font(C2VFont.bodyMedium)
303 + .foregroundColor(C2VColor.encre)
304 + Spacer(minLength: 0)
305 + }
306 + .padding(.vertical, 4)
307 + }
308 + .buttonStyle(.plain)
309 + }
310 + }
311 + }
312 + }
313 +}
314 +
315 +private struct RadioRow: View {
316 + let label: String
317 + let selected: Bool
318 + let onSelect: () -> Void
319 +
320 + var body: some View {
321 + Button(action: onSelect) {
322 + HStack(spacing: 8) {
323 + Image(systemName: selected ? "circle.inset.filled" : "circle")
324 + .foregroundColor(C2VColor.ink)
325 + Text(label)
326 + .font(C2VFont.bodyMedium)
327 + .foregroundColor(C2VColor.encre)
328 + .multilineTextAlignment(.leading)
329 + Spacer(minLength: 0)
330 + }
331 + .padding(.vertical, 2)
332 + }
333 + .buttonStyle(.plain)
334 + }
335 +}
336 +
337 +// Kotlin `displayName` in DuplicateReviewScreen falls back to "#<id>".
338 +private func mergeDisplayName(_ c: CrmContactEntity) -> String {
339 + if let full = c.fullName?.nilIfBlank { return full }
340 + let composed = [c.firstName, c.lastName].compactMap { $0 }
341 + .joined(separator: " ")
342 + .trimmingCharacters(in: .whitespaces)
343 + if !composed.isEmpty { return composed }
344 + if let company = c.company?.nilIfBlank { return company }
345 + return "#\(c.id)"
346 +}
A ios/Card2vcf/UI/Contact/DuplicateReviewViewModel.swift
+121 -0
@@ -0,0 +1,121 @@
1 +import Combine
2 +import Foundation
3 +
4 +// Port of ui/contact/DuplicateReviewViewModel.kt.
5 +
6 +@MainActor
7 +final class DuplicateReviewViewModel: ObservableObject {
8 + @Published private(set) var cluster: [CrmContactEntity] = []
9 + @Published private(set) var selectedIds: Set<Int64> = []
10 + @Published private(set) var merging = false
11 + @Published private(set) var mergeDoneKeepId: Int64?
12 +
13 + let contactId: Int64
14 +
15 + private let repository: ContactRepository
16 + private var cancellables: Set<AnyCancellable> = []
17 +
18 + init(
19 + contactId: Int64,
20 + repository: ContactRepository = ContactRepository(
21 + dao: Card2vcfDatabase.shared.crmContactDao,
22 + images: ContactImageStore()
23 + )
24 + ) {
25 + self.contactId = contactId
26 + self.repository = repository
27 + NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
28 + .receive(on: RunLoop.main)
29 + .sink { [weak self] _ in Task { await self?.refresh() } }
30 + .store(in: &cancellables)
31 + }
32 +
33 + func refresh() async {
34 + do {
35 + let list = try await repository.fetchAll()
36 + let exclusions = try await repository.getExclusions()
37 + let clusters = DuplicateDetector.clusters(contacts: list, exclusions: exclusions)
38 + let memberIds = clusters[contactId] ?? []
39 + cluster = list.filter { memberIds.contains($0.id) }.sorted { $0.id < $1.id }
40 + } catch {
41 + cluster = []
42 + }
43 + }
44 +
45 + func toggleSelect(_ id: Int64) {
46 + guard id != contactId else { return }
47 + if selectedIds.contains(id) {
48 + selectedIds.remove(id)
49 + } else {
50 + selectedIds.insert(id)
51 + }
52 + }
53 +
54 + func deleteSelected() {
55 + let ids = selectedIds
56 + guard !ids.isEmpty else { return }
57 + Task {
58 + for id in ids {
59 + try? await repository.delete(id)
60 + }
61 + selectedIds = []
62 + await refresh()
63 + }
64 + }
65 +
66 + func markSelectedDifferent() {
67 + let ids = selectedIds
68 + guard !ids.isEmpty else { return }
69 + Task {
70 + for id in ids {
71 + try? await repository.markDifferent(contactId, id)
72 + }
73 + selectedIds = []
74 + await refresh()
75 + }
76 + }
77 +
78 + func startMerge() {
79 + merging = true
80 + }
81 +
82 + func cancelMerge() {
83 + merging = false
84 + }
85 +
86 + /// Sources = current contact + selection (or every other member when the
87 + /// selection is empty).
88 + func mergeSources(_ cluster: [CrmContactEntity]) -> [CrmContactEntity] {
89 + let others = cluster.filter { $0.id != contactId }
90 + let peers = selectedIds.isEmpty ? others : others.filter { selectedIds.contains($0.id) }
91 + let primary = cluster.first { $0.id == contactId }
92 + return (primary.map { [$0] } ?? []) + peers
93 + }
94 +
95 + func defaultChoices(_ sources: [CrmContactEntity]) -> MergeFieldChoices {
96 + let keep = sources.contains { $0.id == contactId } ? contactId : sources[0].id
97 + return MergeFieldChoices(
98 + fullNameFromId: keep,
99 + firstNameFromId: keep,
100 + lastNameFromId: keep,
101 + companyFromId: keep,
102 + jobTitleFromId: keep,
103 + websiteFromId: keep,
104 + addressFromId: keep,
105 + notesMode: .keepPrimary,
106 + cardImageFromId: sources.first { $0.cardImagePath?.nilIfBlank != nil }?.id,
107 + profileImageFromId: sources.first { $0.profileImagePath?.nilIfBlank != nil }?.id,
108 + keepId: keep
109 + )
110 + }
111 +
112 + func confirmMerge(sources: [CrmContactEntity], choices: MergeFieldChoices) {
113 + guard sources.count >= 2 else { return }
114 + Task {
115 + try? await repository.mergeContacts(sources: sources, choices: choices)
116 + merging = false
117 + selectedIds = []
118 + mergeDoneKeepId = choices.keepId
119 + }
120 + }
121 +}
A ios/Card2vcf/UI/ImportVcf/VcfImportScreen.swift
+99 -0
@@ -0,0 +1,99 @@
1 +import SwiftUI
2 +import UniformTypeIdentifiers
3 +
4 +// Port of ui/importvcf/VcfImportScreen.kt — .vcf file picker, progress,
5 +// import summary (imported / skipped duplicates / errors).
6 +
7 +struct VcfImportScreen: View {
8 + @StateObject private var viewModel = VcfImportViewModel()
9 + let onBack: () -> Void
10 +
11 + @State private var showPicker = false
12 +
13 + var body: some View {
14 + VStack(spacing: 0) {
15 + ScreenTopBar(title: "Import VCF", onBack: onBack)
16 + content
17 + }
18 + .background(C2VColor.fond)
19 + .toolbar(.hidden, for: .navigationBar)
20 + .fileImporter(
21 + isPresented: $showPicker,
22 + allowedContentTypes: [.vCard, .plainText, .data]
23 + ) { result in
24 + if case .success(let url) = result {
25 + viewModel.importFrom(url: url)
26 + }
27 + }
28 + }
29 +
30 + @ViewBuilder
31 + private var content: some View {
32 + switch viewModel.state {
33 + case .idle:
34 + VStack(spacing: 14) {
35 + Spacer()
36 + Text("Choisissez un fichier vCard (.vcf). Les doublons (e-mail ou téléphone) sont ignorés.")
37 + .font(C2VFont.bodyMedium)
38 + .foregroundColor(C2VColor.texteFaible)
39 + .multilineTextAlignment(.center)
40 + Button("Choisir un fichier") { showPicker = true }
41 + .buttonStyle(C2VPrimaryButtonStyle())
42 + Spacer()
43 + }
44 + .padding(18)
45 + case .loading:
46 + VStack(spacing: 16) {
47 + Spacer()
48 + ProgressView().tint(C2VColor.ink)
49 + Text("Import en cours…")
50 + .font(C2VFont.bodyMedium)
51 + .foregroundColor(C2VColor.texteFaible)
52 + Spacer()
53 + }
54 + .frame(maxWidth: .infinity)
55 + case .done(let result):
56 + VStack(spacing: 12) {
57 + Spacer()
58 + Text("Résumé")
59 + .font(C2VFont.titleLarge)
60 + .foregroundColor(C2VColor.encre)
61 + Text(String(format: "Importés : %d", result.imported))
62 + .font(C2VFont.bodyLarge)
63 + .foregroundColor(C2VColor.encre)
64 + Text(String(format: "Doublons ignorés : %d", result.skippedDuplicates))
65 + .font(C2VFont.bodyMedium)
66 + .foregroundColor(C2VColor.texteFaible)
67 + Text(String(format: "Erreurs : %d", result.errors))
68 + .font(C2VFont.bodyMedium)
69 + .foregroundColor(C2VColor.texteFaible)
70 + Button("Retour au carnet", action: onBack)
71 + .buttonStyle(C2VPrimaryButtonStyle())
72 + Button("Importer un autre fichier") {
73 + viewModel.reset()
74 + showPicker = true
75 + }
76 + .buttonStyle(C2VOutlineButtonStyle())
77 + Spacer()
78 + }
79 + .padding(18)
80 + case .error(let message):
81 + VStack(spacing: 12) {
82 + Spacer()
83 + Text(message)
84 + .font(C2VFont.bodyLarge)
85 + .foregroundColor(C2VColor.encre)
86 + .multilineTextAlignment(.center)
87 + Button("Réessayer") {
88 + viewModel.reset()
89 + showPicker = true
90 + }
91 + .buttonStyle(C2VPrimaryButtonStyle())
92 + Button("Retour", action: onBack)
93 + .buttonStyle(C2VOutlineButtonStyle())
94 + Spacer()
95 + }
96 + .padding(18)
97 + }
98 + }
99 +}
A ios/Card2vcf/UI/ImportVcf/VcfImportViewModel.swift
+55 -0
@@ -0,0 +1,55 @@
1 +import Foundation
2 +
3 +// Port of ui/importvcf/VcfImportViewModel.kt — parse a .vcf file and import
4 +// with the repository's e-mail/phone dedup skip logic.
5 +
6 +enum VcfImportUiState {
7 + case idle
8 + case loading
9 + case done(VcfImportResult)
10 + case error(String)
11 +}
12 +
13 +@MainActor
14 +final class VcfImportViewModel: ObservableObject {
15 + @Published private(set) var state: VcfImportUiState = .idle
16 +
17 + private let repository: ContactRepository
18 +
19 + init(repository: ContactRepository = ContactRepository(
20 + dao: Card2vcfDatabase.shared.crmContactDao,
21 + images: ContactImageStore()
22 + )) {
23 + self.repository = repository
24 + }
25 +
26 + func importFrom(url: URL) {
27 + state = .loading
28 + Task {
29 + do {
30 + let secured = url.startAccessingSecurityScopedResource()
31 + defer {
32 + if secured { url.stopAccessingSecurityScopedResource() }
33 + }
34 + guard let data = try? Data(contentsOf: url),
35 + let text = String(data: data, encoding: .utf8)
36 + ?? String(data: data, encoding: .isoLatin1) else {
37 + throw NSError(
38 + domain: "VcfImport",
39 + code: 1,
40 + userInfo: [NSLocalizedDescriptionKey: "Impossible de lire le fichier"]
41 + )
42 + }
43 + let cards = VCardParser.parse(text)
44 + let result = try await repository.importCards(cards)
45 + state = .done(result)
46 + } catch {
47 + state = .error(error.localizedDescription.nilIfBlank ?? "Erreur d'import")
48 + }
49 + }
50 + }
51 +
52 + func reset() {
53 + state = .idle
54 + }
55 +}
A ios/Card2vcf/UI/Kanban/KanbanBoardView.swift
+254 -0
@@ -0,0 +1,254 @@
1 +import SwiftUI
2 +
3 +// Port of ui/kanban/KanbanBoard.kt — filter chips, horizontal columns,
4 +// ←/→ move buttons, edit/create dialog with an assignee menu.
5 +
6 +private let kanbanColumnWidth: CGFloat = 220
7 +private let kanbanBoardHeight: CGFloat = 360
8 +
9 +struct KanbanBoardView: View {
10 + let colonnes: [KanbanColonne]
11 + let taches: [TacheEntity]
12 + let filterMode: KanbanFilter.Mode
13 + let onFilterModeChange: (KanbanFilter.Mode) -> Void
14 + let assignableUsers: [String]
15 + let onCreateTache: (String, String?) -> Void
16 + let onEditTache: (TacheEntity, String, String?) -> Void
17 + let onDeleteTache: (TacheEntity) -> Void
18 + let onMoveTache: (TacheEntity, Int) -> Void
19 +
20 + @State private var dialogTarget: TacheDialogTarget?
21 +
22 + var body: some View {
23 + VStack(alignment: .leading, spacing: 0) {
24 + HStack {
25 + HStack(spacing: 8) {
26 + FilterChipButton(label: "Mes tâches", selected: filterMode == .mine) {
27 + onFilterModeChange(.mine)
28 + }
29 + FilterChipButton(label: "Toutes", selected: filterMode == .all) {
30 + onFilterModeChange(.all)
31 + }
32 + }
33 + Spacer()
34 + Button("Nouvelle tâche") {
35 + dialogTarget = TacheDialogTarget(tache: nil)
36 + }
37 + .buttonStyle(C2VTextButtonStyle())
38 + .disabled(colonnes.isEmpty)
39 + }
40 + .padding(.vertical, 8)
41 +
42 + if colonnes.isEmpty {
43 + Text("Aucun workflow associé à ce projet")
44 + .font(C2VFont.bodyMedium)
45 + .foregroundColor(C2VColor.texteFaible)
46 + .padding(.vertical, 8)
47 + } else {
48 + ScrollView(.horizontal, showsIndicators: false) {
49 + HStack(alignment: .top, spacing: 12) {
50 + ForEach(Array(colonnes.enumerated()), id: \.element.id) { index, colonne in
51 + KanbanColumnView(
52 + colonne: colonne,
53 + taches: taches
54 + .filter { $0.colonneId == colonne.id }
55 + .sorted { $0.ordre < $1.ordre },
56 + canMoveLeft: index > 0,
57 + canMoveRight: index < colonnes.count - 1,
58 + onEdit: { dialogTarget = TacheDialogTarget(tache: $0) },
59 + onDelete: onDeleteTache,
60 + onMoveLeft: { onMoveTache($0, -1) },
61 + onMoveRight: { onMoveTache($0, 1) }
62 + )
63 + }
64 + }
65 + }
66 + }
67 + }
68 + .sheet(item: $dialogTarget) { target in
69 + TacheDialog(
70 + tache: target.tache,
71 + assignableUsers: assignableUsers,
72 + onDismiss: { dialogTarget = nil },
73 + onConfirm: { titre, assigneA in
74 + if let tache = target.tache {
75 + onEditTache(tache, titre, assigneA)
76 + } else {
77 + onCreateTache(titre, assigneA)
78 + }
79 + dialogTarget = nil
80 + }
81 + )
82 + }
83 + }
84 +}
85 +
86 +private struct TacheDialogTarget: Identifiable {
87 + let id = UUID()
88 + let tache: TacheEntity?
89 +}
90 +
91 +private struct FilterChipButton: View {
92 + let label: String
93 + let selected: Bool
94 + let onTap: () -> Void
95 +
96 + var body: some View {
97 + Button(action: onTap) {
98 + Text(label)
99 + .font(C2VFont.labelMedium)
100 + .foregroundColor(selected ? C2VColor.ink : C2VColor.texteFaible)
101 + .padding(.vertical, 8)
102 + .padding(.horizontal, 12)
103 + .overlay(
104 + Rectangle().stroke(selected ? C2VColor.ink : C2VColor.bordure, lineWidth: 1)
105 + )
106 + }
107 + .buttonStyle(.plain)
108 + }
109 +}
110 +
111 +private struct KanbanColumnView: View {
112 + let colonne: KanbanColonne
113 + let taches: [TacheEntity]
114 + let canMoveLeft: Bool
115 + let canMoveRight: Bool
116 + let onEdit: (TacheEntity) -> Void
117 + let onDelete: (TacheEntity) -> Void
118 + let onMoveLeft: (TacheEntity) -> Void
119 + let onMoveRight: (TacheEntity) -> Void
120 +
121 + var body: some View {
122 + VStack(alignment: .leading, spacing: 0) {
123 + Text(colonne.nom.uppercased())
124 + .font(C2VFont.labelMedium)
125 + .foregroundColor(C2VColor.texteFaible)
126 + .padding(.bottom, 6)
127 + ScrollView {
128 + VStack(spacing: 0) {
129 + ForEach(taches, id: \.localId) { tache in
130 + TacheCard(
131 + tache: tache,
132 + canMoveLeft: canMoveLeft,
133 + canMoveRight: canMoveRight,
134 + onEdit: { onEdit(tache) },
135 + onDelete: { onDelete(tache) },
136 + onMoveLeft: { onMoveLeft(tache) },
137 + onMoveRight: { onMoveRight(tache) }
138 + )
139 + }
140 + }
141 + }
142 + }
143 + .frame(width: kanbanColumnWidth, height: kanbanBoardHeight)
144 + }
145 +}
146 +
147 +private struct TacheCard: View {
148 + let tache: TacheEntity
149 + let canMoveLeft: Bool
150 + let canMoveRight: Bool
151 + let onEdit: () -> Void
152 + let onDelete: () -> Void
153 + let onMoveLeft: () -> Void
154 + let onMoveRight: () -> Void
155 +
156 + var body: some View {
157 + VStack(alignment: .leading, spacing: 4) {
158 + Button(action: onEdit) {
159 + VStack(alignment: .leading, spacing: 2) {
160 + Text(tache.titre.nilIfBlank ?? "—")
161 + .font(C2VFont.bodyMedium)
162 + .foregroundColor(C2VColor.ink)
163 + Text(
164 + tache.assigneA.map { String(format: "Assigné : %@", $0) } ?? "Non assigné"
165 + )
166 + .font(C2VFont.bodySmall)
167 + .foregroundColor(C2VColor.texteFaible)
168 + }
169 + .frame(maxWidth: .infinity, alignment: .leading)
170 + }
171 + .buttonStyle(.plain)
172 + HStack {
173 + Button("←", action: onMoveLeft)
174 + .buttonStyle(C2VTextButtonStyle())
175 + .disabled(!canMoveLeft)
176 + Button("→", action: onMoveRight)
177 + .buttonStyle(C2VTextButtonStyle())
178 + .disabled(!canMoveRight)
179 + Spacer()
180 + Button("Supprimer", action: onDelete)
181 + .buttonStyle(C2VTextButtonStyle())
182 + }
183 + .padding(.top, 4)
184 + }
185 + .padding(10)
186 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
187 + .padding(.vertical, 4)
188 + .contextMenu {
189 + if canMoveLeft {
190 + Button("← Colonne précédente", action: onMoveLeft)
191 + }
192 + if canMoveRight {
193 + Button("→ Colonne suivante", action: onMoveRight)
194 + }
195 + Button("Modifier la tâche", action: onEdit)
196 + Button("Supprimer", role: .destructive, action: onDelete)
197 + }
198 + }
199 +}
200 +
201 +private struct TacheDialog: View {
202 + let tache: TacheEntity?
203 + let assignableUsers: [String]
204 + let onDismiss: () -> Void
205 + let onConfirm: (String, String?) -> Void
206 +
207 + @State private var titre: String
208 + @State private var assigneA: String?
209 +
210 + init(
211 + tache: TacheEntity?,
212 + assignableUsers: [String],
213 + onDismiss: @escaping () -> Void,
214 + onConfirm: @escaping (String, String?) -> Void
215 + ) {
216 + self.tache = tache
217 + self.assignableUsers = assignableUsers
218 + self.onDismiss = onDismiss
219 + self.onConfirm = onConfirm
220 + _titre = State(initialValue: tache?.titre ?? "")
221 + _assigneA = State(initialValue: tache?.assigneA)
222 + }
223 +
224 + var body: some View {
225 + VStack(alignment: .leading, spacing: 14) {
226 + Text(tache == nil ? "Nouvelle tâche" : "Modifier la tâche")
227 + .font(C2VFont.titleLarge)
228 + .foregroundColor(C2VColor.ink)
229 + C2VTextField(label: "", placeholder: "Titre", text: $titre)
230 + Menu {
231 + Button("Non assigné") { assigneA = nil }
232 + ForEach(assignableUsers.distinctPreservingOrder(), id: \.self) { user in
233 + Button(user) { assigneA = user }
234 + }
235 + } label: {
236 + Text(assigneA ?? "Non assigné")
237 + .font(C2VFont.labelMedium)
238 + .foregroundColor(C2VColor.ink)
239 + .padding(.vertical, 8)
240 + }
241 + Spacer()
242 + HStack {
243 + Spacer()
244 + Button("Annuler", action: onDismiss)
245 + .buttonStyle(C2VTextButtonStyle())
246 + Button("Valider") { onConfirm(titre, assigneA) }
247 + .buttonStyle(C2VTextButtonStyle())
248 + }
249 + }
250 + .padding(18)
251 + .background(C2VColor.fond)
252 + .presentationDetents([.medium])
253 + }
254 +}
A ios/Card2vcf/UI/MainTabRow.swift
+41 -0
@@ -0,0 +1,41 @@
1 +import SwiftUI
2 +
3 +// Port of ui/nav/MainTabRow.kt — editorial Carnet | Projets tabs,
4 +// no Material navigation bar.
5 +
6 +enum MainTab: CaseIterable {
7 + case carnet
8 + case projets
9 +
10 + var label: String {
11 + switch self {
12 + case .carnet: return "Carnet"
13 + case .projets: return "Projets"
14 + }
15 + }
16 +}
17 +
18 +struct MainTabRow: View {
19 + let current: MainTab
20 + let onSelect: (MainTab) -> Void
21 +
22 + var body: some View {
23 + HStack(spacing: 4) {
24 + ForEach(MainTab.allCases, id: \.self) { tab in
25 + let selected = tab == current
26 + Button {
27 + onSelect(tab)
28 + } label: {
29 + Text(tab.label)
30 + .font(selected ? C2VFont.labelLarge : C2VFont.labelMedium)
31 + .foregroundColor(selected ? C2VColor.ink : C2VColor.texteFaible)
32 + .padding(.vertical, 8)
33 + .padding(.horizontal, 8)
34 + }
35 + .buttonStyle(.plain)
36 + }
37 + Spacer()
38 + }
39 + .frame(maxWidth: .infinity)
40 + }
41 +}
A ios/Card2vcf/UI/Projets/ProjetDetailScreen.swift
+137 -0
@@ -0,0 +1,137 @@
1 +import SwiftUI
2 +
3 +// Port of ui/projets/ProjetDetailScreen.kt — description, membres, kanban
4 +// board, and the compte-rendu (interaction) section on a contact.
5 +
6 +struct ProjetDetailScreen: View {
7 + @StateObject private var viewModel: ProjetDetailViewModel
8 + let onBack: () -> Void
9 +
10 + init(serverId: String, onBack: @escaping () -> Void) {
11 + _viewModel = StateObject(wrappedValue: ProjetDetailViewModel(projetServerId: serverId))
12 + self.onBack = onBack
13 + }
14 +
15 + var body: some View {
16 + VStack(spacing: 0) {
17 + ScreenTopBar(title: viewModel.projet?.nom ?? "", onBack: onBack)
18 + ScrollView {
19 + VStack(alignment: .leading, spacing: 0) {
20 + Spacer().frame(height: 12)
21 + if let description = viewModel.projet?.description.nilIfBlank {
22 + Text(description)
23 + .font(C2VFont.bodyMedium)
24 + .foregroundColor(C2VColor.texteFaible)
25 + Spacer().frame(height: 8)
26 + }
27 + if !viewModel.membres.isEmpty {
28 + Text("Membres")
29 + .font(C2VFont.labelMedium)
30 + .foregroundColor(C2VColor.texteFaible)
31 + ForEach(viewModel.membres, id: \.self) { membre in
32 + Text("\(membre.utilisateur) — \(membre.niveau)")
33 + .font(C2VFont.bodyMedium)
34 + .foregroundColor(C2VColor.ink)
35 + }
36 + }
37 + C2VDivider().padding(.vertical, 12)
38 +
39 + KanbanBoardView(
40 + colonnes: viewModel.colonnes,
41 + taches: viewModel.visibleTaches,
42 + filterMode: viewModel.filterMode,
43 + onFilterModeChange: { viewModel.setFilterMode($0) },
44 + assignableUsers: viewModel.assignableUsers,
45 + onCreateTache: { titre, assigneA in
46 + viewModel.createTache(titre: titre, assigneA: assigneA)
47 + },
48 + onEditTache: { tache, titre, assigneA in
49 + viewModel.editTache(tache, titre: titre, assigneA: assigneA)
50 + },
51 + onDeleteTache: { viewModel.deleteTache($0) },
52 + onMoveTache: { tache, direction in
53 + viewModel.moveTache(tache, direction: direction)
54 + }
55 + )
56 +
57 + C2VDivider().padding(.vertical, 12)
58 +
59 + crSection
60 +
61 + Spacer().frame(height: 24)
62 + }
63 + .padding(.horizontal, 18)
64 + }
65 + }
66 + .background(C2VColor.fond)
67 + .toolbar(.hidden, for: .navigationBar)
68 + .task { await viewModel.refresh() }
69 + }
70 +
71 + private var crSection: some View {
72 + VStack(alignment: .leading, spacing: 0) {
73 + Text("Compte-rendu")
74 + .font(C2VFont.labelMedium)
75 + .foregroundColor(C2VColor.texteFaible)
76 + Spacer().frame(height: 6)
77 +
78 + let selectedContact = viewModel.contactsAvecServerId.first {
79 + $0.serverId == viewModel.crContactServerId
80 + }
81 + Menu {
82 + ForEach(viewModel.contactsAvecServerId, id: \.id) { contact in
83 + Button(crContactName(contact)) {
84 + viewModel.selectCrContact(contact.serverId)
85 + }
86 + }
87 + } label: {
88 + Text(selectedContact.map(crContactName) ?? "Choisir un contact")
89 + .font(C2VFont.labelMedium)
90 + .foregroundColor(C2VColor.ink)
91 + .padding(.vertical, 8)
92 + }
93 +
94 + if viewModel.crContactServerId != nil {
95 + Spacer().frame(height: 8)
96 + C2VTextField(label: "", placeholder: "Sujet", text: $viewModel.crSujet)
97 + Spacer().frame(height: 8)
98 + C2VTextField(
99 + label: "",
100 + placeholder: "Description",
101 + text: $viewModel.crDescription,
102 + minLines: 2
103 + )
104 + Spacer().frame(height: 8)
105 + Button("Ajouter") { viewModel.submitCr() }
106 + .buttonStyle(C2VTextButtonStyle())
107 + .disabled(viewModel.crSujet.isBlank)
108 +
109 + Spacer().frame(height: 8)
110 + ForEach(viewModel.crInteractions, id: \.localId) { interaction in
111 + VStack(alignment: .leading, spacing: 2) {
112 + Text(interaction.sujet)
113 + .font(C2VFont.bodyMedium)
114 + .foregroundColor(C2VColor.ink)
115 + if interaction.description.isNotBlank {
116 + Text(interaction.description)
117 + .font(C2VFont.bodySmall)
118 + .foregroundColor(C2VColor.texteFaible)
119 + }
120 + }
121 + .frame(maxWidth: .infinity, alignment: .leading)
122 + .padding(.vertical, 6)
123 + C2VDivider()
124 + }
125 + }
126 + }
127 + }
128 +
129 + // Kotlin `contactDisplayName` in ProjetDetailScreen (no company fallback).
130 + private func crContactName(_ contact: CrmContactEntity) -> String {
131 + if let full = contact.fullName?.nilIfBlank { return full }
132 + let composed = [contact.firstName, contact.lastName].compactMap { $0 }
133 + .joined(separator: " ")
134 + .trimmingCharacters(in: .whitespaces)
135 + return composed.isEmpty ? "—" : composed
136 + }
137 +}
A ios/Card2vcf/UI/Projets/ProjetDetailViewModel.swift
+334 -0
@@ -0,0 +1,334 @@
1 +import Combine
2 +import Foundation
3 +
4 +// Port of ui/projets/ProjetDetailViewModel.kt — membres, workflow kanban
5 +// columns, tasks, and compte-rendu (interaction) on a contact.
6 +// Sync-op payload JSON matches the Android `syncJson` config: snake_case keys
7 +// with defaults encoded (SyncModels.kt).
8 +
9 +/// UI-side mirror of the Android `MembreProjetDto` stored in `membresJson`.
10 +struct ProjetMembre: Decodable, Hashable {
11 + var utilisateur: String
12 + var niveau: String = "lecteur"
13 +
14 + private enum CodingKeys: String, CodingKey {
15 + case utilisateur
16 + case niveau
17 + }
18 +
19 + init(from decoder: Decoder) throws {
20 + let container = try decoder.container(keyedBy: CodingKeys.self)
21 + utilisateur = try container.decode(String.self, forKey: .utilisateur)
22 + niveau = try container.decodeIfPresent(String.self, forKey: .niveau) ?? "lecteur"
23 + }
24 +}
25 +
26 +/// UI-side mirror of the Android `ColonneDto` stored in `colonnesJson`.
27 +struct KanbanColonne: Decodable, Hashable, Identifiable {
28 + var id: String
29 + var nom: String
30 + var ordre: Int = 0
31 +
32 + private enum CodingKeys: String, CodingKey {
33 + case id
34 + case nom
35 + case ordre
36 + }
37 +
38 + init(from decoder: Decoder) throws {
39 + let container = try decoder.container(keyedBy: CodingKeys.self)
40 + id = try container.decode(String.self, forKey: .id)
41 + nom = try container.decode(String.self, forKey: .nom)
42 + ordre = try container.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
43 + }
44 +}
45 +
46 +@MainActor
47 +final class ProjetDetailViewModel: ObservableObject {
48 + @Published private(set) var projet: ProjetEntity?
49 + @Published private(set) var membres: [ProjetMembre] = []
50 + @Published private(set) var colonnes: [KanbanColonne] = []
51 + @Published private(set) var taches: [TacheEntity] = []
52 + @Published private(set) var filterMode: KanbanFilter.Mode = .mine
53 + @Published private(set) var contactsAvecServerId: [CrmContactEntity] = []
54 + @Published private(set) var crContactServerId: String?
55 + @Published var crSujet = ""
56 + @Published var crDescription = ""
57 + @Published private(set) var crInteractions: [InteractionEntity] = []
58 +
59 + let projetServerId: String
60 + let userName: String
61 +
62 + private let database: Card2vcfDatabase
63 + private var cancellables: Set<AnyCancellable> = []
64 +
65 + init(
66 + projetServerId: String,
67 + database: Card2vcfDatabase = .shared,
68 + userName: String = SyncUserContext.userName
69 + ) {
70 + self.projetServerId = projetServerId
71 + self.database = database
72 + self.userName = userName
73 + NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
74 + .receive(on: RunLoop.main)
75 + .sink { [weak self] _ in Task { await self?.refresh() } }
76 + .store(in: &cancellables)
77 + }
78 +
79 + var visibleTaches: [TacheEntity] {
80 + KanbanFilter.filter(taches, mode: filterMode, userName: userName)
81 + }
82 +
83 + var assignableUsers: [String] {
84 + (membres.map { $0.utilisateur } + [userName]).filter { $0.isNotBlank }
85 + }
86 +
87 + func refresh() async {
88 + let db = database
89 + projet = try? await db.projetDao.getByServerId(projetServerId)
90 + membres = Self.parseMembres(projet?.membresJson ?? "[]")
91 + let workflowServerId = projet?.workflowServerId ?? ""
92 + if workflowServerId.isNotBlank,
93 + let workflow = try? await db.workflowDao.getByServerId(workflowServerId) {
94 + colonnes = Self.parseColonnes(workflow.colonnesJson).sorted { $0.ordre < $1.ordre }
95 + } else {
96 + colonnes = []
97 + }
98 + taches = (try? await db.tacheDao.fetchByProjetServerId(projetServerId)) ?? []
99 + let allContacts = (try? await db.crmContactDao.fetchAll()) ?? []
100 + contactsAvecServerId = allContacts.filter { $0.serverId != nil }
101 + await refreshInteractions()
102 + }
103 +
104 + func setFilterMode(_ mode: KanbanFilter.Mode) {
105 + filterMode = mode
106 + }
107 +
108 + func selectCrContact(_ serverId: String?) {
109 + crContactServerId = serverId
110 + Task { await refreshInteractions() }
111 + }
112 +
113 + private func refreshInteractions() async {
114 + guard let contactServerId = crContactServerId else {
115 + crInteractions = []
116 + return
117 + }
118 + crInteractions = (try? await database.interactionDao.fetchByContactServerId(contactServerId)) ?? []
119 + }
120 +
121 + func submitCr() {
122 + guard let contactServerId = crContactServerId else { return }
123 + let sujet = crSujet.trimmingCharacters(in: .whitespacesAndNewlines)
124 + guard !sujet.isEmpty else { return }
125 + let description = crDescription.trimmingCharacters(in: .whitespacesAndNewlines)
126 + Task {
127 + let now = ContactRepository.currentMillis()
128 + _ = try? await database.interactionDao.upsert(
129 + InteractionEntity(
130 + contactServerId: contactServerId,
131 + sujet: sujet,
132 + description: description,
133 + creePar: userName,
134 + createdAt: now
135 + )
136 + )
137 + _ = try? await database.syncOpDao.insert(
138 + SyncOpEntity(
139 + entityType: "interaction",
140 + op: "create",
141 + payloadJson: Self.encodeJson(["sujet": sujet, "description": description]),
142 + serverId: contactServerId,
143 + createdAt: now
144 + )
145 + )
146 + crSujet = ""
147 + crDescription = ""
148 + await refresh()
149 + }
150 + }
151 +
152 + func createTache(titre: String, assigneA: String?) {
153 + let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
154 + guard !titreTrim.isEmpty else { return }
155 + guard let colonneId = colonnes.first?.id else { return }
156 + Task {
157 + let now = ContactRepository.currentMillis()
158 + let localId = (try? await database.tacheDao.upsert(
159 + TacheEntity(
160 + projetLocalId: projet?.localId,
161 + projetServerId: projetServerId,
162 + titre: titreTrim,
163 + colonneId: colonneId,
164 + assigneA: assigneA,
165 + auteur: userName,
166 + createdAt: now,
167 + updatedAt: now
168 + )
169 + )) ?? 0
170 + _ = try? await database.syncOpDao.insert(
171 + SyncOpEntity(
172 + entityType: "tache",
173 + op: "create",
174 + payloadJson: Self.createTachePayload(
175 + titre: titreTrim,
176 + description: "",
177 + colonneId: colonneId,
178 + assigneA: assigneA
179 + ),
180 + localId: localId,
181 + createdAt: now
182 + )
183 + )
184 + await refresh()
185 + }
186 + }
187 +
188 + func editTache(_ tache: TacheEntity, titre: String, assigneA: String?) {
189 + let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
190 + guard !titreTrim.isEmpty else { return }
191 + Task {
192 + let now = ContactRepository.currentMillis()
193 + var updated = tache
194 + updated.titre = titreTrim
195 + updated.assigneA = assigneA
196 + updated.updatedAt = now
197 + _ = try? await database.tacheDao.upsert(updated)
198 + let payload = Self.encodeJson([
199 + "titre": titreTrim,
200 + "description": tache.description,
201 + "assigne_a": assigneA as Any,
202 + "assigne_a_pose": true
203 + ])
204 + await enqueueTacheMutation(tache, op: "update", payloadJson: payload) {
205 + Self.createTachePayload(
206 + titre: titreTrim,
207 + description: tache.description,
208 + colonneId: tache.colonneId,
209 + assigneA: assigneA
210 + )
211 + }
212 + await refresh()
213 + }
214 + }
215 +
216 + func deleteTache(_ tache: TacheEntity) {
217 + Task {
218 + if let serverId = tache.serverId {
219 + try? await database.tacheDao.deleteByServerId(serverId)
220 + _ = try? await database.syncOpDao.insert(
221 + SyncOpEntity(
222 + entityType: "tache",
223 + op: "delete",
224 + serverId: serverId,
225 + createdAt: ContactRepository.currentMillis()
226 + )
227 + )
228 + } else {
229 + try? await database.tacheDao.deleteByLocalId(tache.localId)
230 + await clearPendingCreate(tache.localId)
231 + }
232 + await refresh()
233 + }
234 + }
235 +
236 + func moveTache(_ tache: TacheEntity, direction: Int) {
237 + let ordered = colonnes
238 + guard let currentIndex = ordered.firstIndex(where: { $0.id == tache.colonneId }) else { return }
239 + let targetIndex = currentIndex + direction
240 + guard ordered.indices.contains(targetIndex) else { return }
241 + let targetColonneId = ordered[targetIndex].id
242 + Task {
243 + let now = ContactRepository.currentMillis()
244 + var moved = tache
245 + moved.colonneId = targetColonneId
246 + moved.updatedAt = now
247 + _ = try? await database.tacheDao.upsert(moved)
248 + let payload = Self.encodeJson(["colonne_id": targetColonneId])
249 + await enqueueTacheMutation(tache, op: "move", payloadJson: payload) {
250 + Self.createTachePayload(
251 + titre: tache.titre,
252 + description: tache.description,
253 + colonneId: targetColonneId,
254 + assigneA: tache.assigneA
255 + )
256 + }
257 + await refresh()
258 + }
259 + }
260 +
261 + /// When the task has no `serverId` yet (create not synchronized), the
262 + /// pending create op is patched instead of enqueuing the mutation.
263 + private func enqueueTacheMutation(
264 + _ tache: TacheEntity,
265 + op: String,
266 + payloadJson: String,
267 + fallbackCreatePayload: () -> String
268 + ) async {
269 + if let serverId = tache.serverId {
270 + _ = try? await database.syncOpDao.insert(
271 + SyncOpEntity(
272 + entityType: "tache",
273 + op: op,
274 + payloadJson: payloadJson,
275 + serverId: serverId,
276 + createdAt: ContactRepository.currentMillis()
277 + )
278 + )
279 + return
280 + }
281 + let ops = (try? await database.syncOpDao.listAll()) ?? []
282 + if var pendingCreate = ops.first(where: {
283 + $0.entityType == "tache" && $0.op == "create" && $0.localId == tache.localId
284 + }) {
285 + pendingCreate.payloadJson = fallbackCreatePayload()
286 + _ = try? await database.syncOpDao.insert(pendingCreate)
287 + }
288 + }
289 +
290 + private func clearPendingCreate(_ localId: Int64) async {
291 + let ops = (try? await database.syncOpDao.listAll()) ?? []
292 + for op in ops where op.entityType == "tache" && op.localId == localId {
293 + try? await database.syncOpDao.deleteById(op.id)
294 + }
295 + }
296 +
297 + // MARK: - JSON helpers (snake_case, defaults encoded — Android syncJson)
298 +
299 + static func createTachePayload(
300 + titre: String,
301 + description: String,
302 + colonneId: String,
303 + assigneA: String?
304 + ) -> String {
305 + encodeJson([
306 + "titre": titre,
307 + "description": description,
308 + "colonne_id": colonneId,
309 + "assigne_a": assigneA as Any
310 + ])
311 + }
312 +
313 + static func encodeJson(_ object: [String: Any]) -> String {
314 + let sanitized = object.mapValues { value -> Any in
315 + if case Optional<Any>.none = value { return NSNull() }
316 + return value
317 + }
318 + guard
319 + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]),
320 + let text = String(data: data, encoding: .utf8)
321 + else { return "{}" }
322 + return text
323 + }
324 +
325 + static func parseMembres(_ json: String) -> [ProjetMembre] {
326 + guard let data = json.data(using: .utf8) else { return [] }
327 + return (try? JSONDecoder().decode([ProjetMembre].self, from: data)) ?? []
328 + }
329 +
330 + static func parseColonnes(_ json: String) -> [KanbanColonne] {
331 + guard let data = json.data(using: .utf8) else { return [] }
332 + return (try? JSONDecoder().decode([KanbanColonne].self, from: data)) ?? []
333 + }
334 +}
A ios/Card2vcf/UI/Projets/ProjetsListScreen.swift
+110 -0
@@ -0,0 +1,110 @@
1 +import SwiftUI
2 +
3 +// Port of ui/projets/ProjetsListScreen.kt — header, Carnet | Projets tabs,
4 +// hairline-divided project rows.
5 +
6 +struct ProjetsListScreen: View {
7 + @ObservedObject var viewModel: ProjetsViewModel
8 + let onOpenCarnet: () -> Void
9 + let onOpenProjet: (String) -> Void
10 + let onSettings: () -> Void
11 + @ObservedObject var syncViewModel: SyncChromeViewModel
12 +
13 + var body: some View {
14 + VStack(spacing: 0) {
15 + HStack(spacing: 0) {
16 + Text("Projets".uppercased())
17 + .font(C2VFont.titleLarge)
18 + .foregroundColor(C2VColor.ink)
19 + Spacer()
20 + if syncViewModel.configured {
21 + Button(action: { syncViewModel.syncNow() }) {
22 + Image(systemName: "arrow.triangle.2.circlepath")
23 + .foregroundColor(C2VColor.ink)
24 + .frame(width: 44, height: 44)
25 + }
26 + .accessibilityLabel("Synchroniser")
27 + }
28 + Button(action: onSettings) {
29 + Image(systemName: "gearshape")
30 + .foregroundColor(C2VColor.ink)
31 + .frame(width: 44, height: 44)
32 + }
33 + .accessibilityLabel("Paramètres")
34 + }
35 + .padding(.top, 12)
36 + .padding(.bottom, 8)
37 +
38 + MainTabRow(current: .projets) { tab in
39 + if tab == .carnet { onOpenCarnet() }
40 + }
41 +
42 + SyncBanner(
43 + pendingChanges: syncViewModel.pendingRemoteChanges,
44 + localAhead: syncViewModel.localCalendarAhead,
45 + syncing: syncViewModel.syncing,
46 + error: syncViewModel.error,
47 + onSyncClick: { syncViewModel.syncNow() }
48 + )
49 +
50 + Spacer().frame(height: 8)
51 +
52 + if viewModel.projets.isEmpty {
53 + Spacer()
54 + Text("Aucun projet — synchronisez pour les récupérer")
55 + .font(C2VFont.bodyLarge)
56 + .foregroundColor(C2VColor.texteFaible)
57 + .multilineTextAlignment(.center)
58 + Spacer()
59 + } else {
60 + ScrollView {
61 + LazyVStack(alignment: .leading, spacing: 0) {
62 + ForEach(viewModel.projets, id: \.localId) { projet in
63 + ProjetRow(projet: projet) {
64 + if let serverId = projet.serverId {
65 + onOpenProjet(serverId)
66 + }
67 + }
68 + }
69 + }
70 + }
71 + }
72 + }
73 + .padding(.horizontal, 18)
74 + .background(C2VColor.fond)
75 + .syncConflictDialog(
76 + conflict: syncViewModel.conflictPending,
77 + onConfirm: { syncViewModel.confirmConflictCancellation() },
78 + onDismiss: { syncViewModel.dismissConflict() }
79 + )
80 + .toolbar(.hidden, for: .navigationBar)
81 + .task { await viewModel.refresh() }
82 + .onAppear { syncViewModel.refreshStatus() }
83 + }
84 +}
85 +
86 +private struct ProjetRow: View {
87 + let projet: ProjetEntity
88 + let onTap: () -> Void
89 +
90 + var body: some View {
91 + Button(action: onTap) {
92 + VStack(alignment: .leading, spacing: 0) {
93 + VStack(alignment: .leading, spacing: 2) {
94 + Text(projet.nom.nilIfBlank ?? "—")
95 + .font(C2VFont.bodyLarge)
96 + .foregroundColor(C2VColor.ink)
97 + if projet.description.isNotBlank {
98 + Text(projet.description)
99 + .font(C2VFont.bodyMedium)
100 + .foregroundColor(C2VColor.texteFaible)
101 + }
102 + }
103 + .padding(.vertical, 10)
104 + .frame(maxWidth: .infinity, alignment: .leading)
105 + C2VDivider()
106 + }
107 + }
108 + .buttonStyle(.plain)
109 + }
110 +}
A ios/Card2vcf/UI/Projets/ProjetsViewModel.swift
+24 -0
@@ -0,0 +1,24 @@
1 +import Combine
2 +import Foundation
3 +
4 +// Port of ui/projets/ProjetsViewModel.kt.
5 +
6 +@MainActor
7 +final class ProjetsViewModel: ObservableObject {
8 + @Published private(set) var projets: [ProjetEntity] = []
9 +
10 + private let projetDao: ProjetDao
11 + private var cancellables: Set<AnyCancellable> = []
12 +
13 + init(projetDao: ProjetDao = Card2vcfDatabase.shared.projetDao) {
14 + self.projetDao = projetDao
15 + NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
16 + .receive(on: RunLoop.main)
17 + .sink { [weak self] _ in Task { await self?.refresh() } }
18 + .store(in: &cancellables)
19 + }
20 +
21 + func refresh() async {
22 + projets = (try? await projetDao.fetchAll()) ?? []
23 + }
24 +}
A ios/Card2vcf/UI/RootView.swift
+128 -0
@@ -0,0 +1,128 @@
1 +import SwiftUI
2 +
3 +// Port of ui/nav/Card2vcfNavHost.kt — NavigationStack + Carnet | Projets tabs.
4 +// Deep flows: scan → draft → fiche, fiche → edit → rescan, duplicates → merge
5 +// → fiche, carnet → import.
6 +
7 +enum AppRoute: Hashable {
8 + case fiche(contactId: Int64, sort: ContactSort)
9 + case edit(contactId: Int64)
10 + case duplicates(contactId: Int64)
11 + case scan(editId: Int64?)
12 + case manual
13 + case importVcf
14 + case settings
15 + case projetDetail(serverId: String)
16 +}
17 +
18 +struct RootView: View {
19 + @State private var path: [AppRoute] = []
20 + @State private var tab: MainTab = .carnet
21 +
22 + // Long-lived view models (Android scoped them to the NavHost).
23 + @StateObject private var carnetVm = CarnetViewModel()
24 + @StateObject private var projetsVm = ProjetsViewModel()
25 + @StateObject private var scanVm = ScanCarteViewModel()
26 + @StateObject private var settingsVm = SettingsViewModel()
27 + @StateObject private var syncVm = SyncChromeViewModel()
28 +
29 + var body: some View {
30 + NavigationStack(path: $path) {
31 + rootTab
32 + .navigationDestination(for: AppRoute.self) { route in
33 + destination(route)
34 + }
35 + }
36 + .tint(C2VColor.ink)
37 + }
38 +
39 + @ViewBuilder
40 + private var rootTab: some View {
41 + switch tab {
42 + case .carnet:
43 + CarnetScreen(
44 + viewModel: carnetVm,
45 + onOpenContact: { id, sort in path.append(.fiche(contactId: id, sort: sort)) },
46 + onScan: { path.append(.scan(editId: nil)) },
47 + onManual: { path.append(.manual) },
48 + onImportVcf: { path.append(.importVcf) },
49 + onSettings: { path.append(.settings) },
50 + onOpenProjets: { tab = .projets },
51 + syncViewModel: syncVm
52 + )
53 + case .projets:
54 + ProjetsListScreen(
55 + viewModel: projetsVm,
56 + onOpenCarnet: { tab = .carnet },
57 + onOpenProjet: { serverId in path.append(.projetDetail(serverId: serverId)) },
58 + onSettings: { path.append(.settings) },
59 + syncViewModel: syncVm
60 + )
61 + }
62 + }
63 +
64 + @ViewBuilder
65 + private func destination(_ route: AppRoute) -> some View {
66 + switch route {
67 + case .fiche(let contactId, let sort):
68 + ContactPagerScreen(
69 + contactId: contactId,
70 + sort: sort,
71 + onBack: { pop() },
72 + onEdit: { id in path.append(.edit(contactId: id)) },
73 + onDuplicates: { id in path.append(.duplicates(contactId: id)) },
74 + onDeleted: { path = [] }
75 + )
76 + case .edit(let contactId):
77 + ContactEditScreen(
78 + contactId: contactId,
79 + onBack: { pop() },
80 + onSaved: { pop() },
81 + onRescan: { id in path.append(.scan(editId: id)) }
82 + )
83 + case .duplicates(let contactId):
84 + DuplicateReviewScreen(
85 + contactId: contactId,
86 + onBack: { pop() },
87 + onMerged: { keepId in
88 + // Kotlin: navigate fiche(keepId) popUpTo(carnet).
89 + path = [.fiche(contactId: keepId, sort: .lastName)]
90 + }
91 + )
92 + case .scan(let editId):
93 + ScanCarteScreen(
94 + viewModel: scanVm,
95 + onBack: { pop() },
96 + onContactSaved: { id in
97 + scanVm.editingContactId = nil
98 + scanVm.reset()
99 + path = [.fiche(contactId: id, sort: .lastName)]
100 + }
101 + )
102 + .onAppear {
103 + // Kotlin LaunchedEffect(editId).
104 + scanVm.editingContactId = editId
105 + scanVm.reset()
106 + }
107 + case .manual:
108 + ManualContactScreen(
109 + onBack: { pop() },
110 + onSaved: { id in
111 + path = [.fiche(contactId: id, sort: .lastName)]
112 + }
113 + )
114 + case .importVcf:
115 + VcfImportScreen(onBack: { pop() })
116 + case .settings:
117 + SettingsScreen(viewModel: settingsVm, onBack: { pop() })
118 + case .projetDetail(let serverId):
119 + ProjetDetailScreen(serverId: serverId, onBack: { pop() })
120 + }
121 + }
122 +
123 + private func pop() {
124 + if !path.isEmpty {
125 + path.removeLast()
126 + }
127 + }
128 +}
A ios/Card2vcf/UI/Scan/ContactDraftFieldsView.swift
+92 -0
@@ -0,0 +1,92 @@
1 +import SwiftUI
2 +
3 +// Port of ui/ContactDraftFields.kt. When `phonesAsList` is set (edit screen),
4 +// phones/e-mails render as editable per-item lists instead of the Android
5 +// comma-joined single field.
6 +
7 +struct ContactDraftFieldsView: View {
8 + @Binding var card: ContactCard
9 + var phonesAsList: Bool = false
10 +
11 + var body: some View {
12 + VStack(alignment: .leading, spacing: 10) {
13 + C2VTextField(label: "Nom complet", text: optionalBinding(\.fullName))
14 + C2VTextField(label: "Prénom", text: optionalBinding(\.firstName))
15 + C2VTextField(label: "Nom de famille", text: optionalBinding(\.lastName))
16 + C2VTextField(label: "Société", text: optionalBinding(\.company))
17 + C2VTextField(label: "Poste", text: optionalBinding(\.jobTitle))
18 + if phonesAsList {
19 + EditableStringList(label: "Téléphone(s)", items: $card.phones)
20 + EditableStringList(label: "E-mail(s)", items: $card.emails)
21 + } else {
22 + C2VTextField(label: "Téléphone(s)", text: joinedBinding(\.phones))
23 + C2VTextField(label: "E-mail(s)", text: joinedBinding(\.emails))
24 + }
25 + C2VTextField(label: "Site web", text: optionalBinding(\.website))
26 + C2VTextField(label: "Adresse", text: optionalBinding(\.address))
27 + C2VTextField(label: "Note", text: optionalBinding(\.note), minLines: 2)
28 + }
29 + }
30 +
31 + private func optionalBinding(_ keyPath: WritableKeyPath<ContactCard, String?>) -> Binding<String> {
32 + Binding(
33 + get: { card[keyPath: keyPath] ?? "" },
34 + set: { card[keyPath: keyPath] = $0.nilIfBlank }
35 + )
36 + }
37 +
38 + // Kotlin joined with ", " and split on ','/';'.
39 + private func joinedBinding(_ keyPath: WritableKeyPath<ContactCard, [String]>) -> Binding<String> {
40 + Binding(
41 + get: { card[keyPath: keyPath].joined(separator: ", ") },
42 + set: { value in
43 + card[keyPath: keyPath] = value
44 + .split(whereSeparator: { $0 == "," || $0 == ";" })
45 + .map { $0.trimmingCharacters(in: .whitespaces) }
46 + .filter { !$0.isEmpty }
47 + }
48 + )
49 + }
50 +}
51 +
52 +/// Editable list of strings: one square field per entry, remove button, add row.
53 +struct EditableStringList: View {
54 + let label: String
55 + @Binding var items: [String]
56 +
57 + var body: some View {
58 + VStack(alignment: .leading, spacing: 6) {
59 + Text(label)
60 + .font(C2VFont.labelSmall)
61 + .foregroundColor(C2VColor.body)
62 + ForEach(items.indices, id: \.self) { index in
63 + HStack(spacing: 8) {
64 + C2VTextField(label: "", text: Binding(
65 + get: { index < items.count ? items[index] : "" },
66 + set: { value in
67 + if index < items.count { items[index] = value }
68 + }
69 + ))
70 + Button {
71 + if index < items.count { items.remove(at: index) }
72 + } label: {
73 + Image(systemName: "minus")
74 + .foregroundColor(C2VColor.ink)
75 + .frame(width: 32, height: 32)
76 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
77 + }
78 + .accessibilityLabel("Supprimer")
79 + }
80 + }
81 + Button {
82 + items.append("")
83 + } label: {
84 + Text("Ajouter")
85 + .font(C2VFont.labelMedium)
86 + .foregroundColor(C2VColor.ink)
87 + .padding(.vertical, 4)
88 + }
89 + .buttonStyle(.plain)
90 + }
91 + }
92 +}
A ios/Card2vcf/UI/Scan/ContactDraftScreen.swift
+82 -0
@@ -0,0 +1,82 @@
1 +import SwiftUI
2 +
3 +// Port of ui/ContactDraftScreen.kt — editable draft of the parsed card, raw
4 +// OCR text, then: save to carnet / system contact / VCF export / retry.
5 +
6 +struct ContactDraftScreen: View {
7 + let draft: ContactDraftUi
8 + let onCardChange: (ContactCard) -> Void
9 + let onSaveToCarnet: () -> Void
10 + let onRetry: () -> Void
11 + var isEditing: Bool = false
12 +
13 + @State private var showSystemContact = false
14 +
15 + var body: some View {
16 + VStack(alignment: .leading, spacing: 14) {
17 + Text("Vérifier le contact")
18 + .font(C2VFont.titleLarge)
19 + .foregroundColor(C2VColor.encre)
20 + Text("Corrigez les champs, enregistrez dans le carnet, ou exportez vers Contacts / VCF.")
21 + .font(C2VFont.bodyMedium)
22 + .foregroundColor(C2VColor.texteFaible)
23 + ScrollView {
24 + VStack(alignment: .leading, spacing: 14) {
25 + ContactDraftFieldsView(card: Binding(
26 + get: { draft.card },
27 + set: { onCardChange($0) }
28 + ))
29 + Text("Texte OCR brut")
30 + .font(C2VFont.bodyMedium)
31 + .foregroundColor(C2VColor.encre)
32 + Text(draft.rawOcrText)
33 + .font(.system(size: 12, design: .monospaced))
34 + .foregroundColor(C2VColor.texteFaible)
35 + .textSelection(.enabled)
36 + .frame(maxWidth: .infinity, alignment: .leading)
37 + .padding(12)
38 + .background(C2VColor.surface)
39 + .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1))
40 + }
41 + }
42 + Button(isEditing ? "Mettre à jour" : "Enregistrer", action: onSaveToCarnet)
43 + .buttonStyle(C2VPrimaryButtonStyle())
44 + Button("Créer le contact") { showSystemContact = true }
45 + .buttonStyle(C2VOutlineButtonStyle())
46 + ShareLink(
47 + item: VcfShareFile(card: draft.card),
48 + preview: SharePreview(VcfShare.fileName(draft.card))
49 + ) {
50 + Text("Exporter VCF")
51 + }
52 + .buttonStyle(C2VOutlineButtonStyle())
53 + Button("Reprendre la capture", action: onRetry)
54 + .buttonStyle(C2VOutlineButtonStyle())
55 + }
56 + .padding(.horizontal, 18)
57 + .padding(.vertical, 16)
58 + .background(C2VColor.fond)
59 + .sheet(isPresented: $showSystemContact) {
60 + SystemContactView(card: draft.card) {
61 + showSystemContact = false
62 + }
63 + }
64 + }
65 +}
66 +
67 +/// Kotlin `ScanProgress`.
68 +struct ScanProgressView: View {
69 + let message: String
70 +
71 + var body: some View {
72 + VStack(spacing: 16) {
73 + ProgressView()
74 + .tint(C2VColor.ink)
75 + Text(message)
76 + .font(C2VFont.bodyMedium)
77 + .foregroundColor(C2VColor.texteFaible)
78 + }
79 + .frame(maxWidth: .infinity, maxHeight: .infinity)
80 + .background(C2VColor.fond)
81 + }
82 +}
A ios/Card2vcf/UI/Scan/ScanCarteScreen.swift
+119 -0
@@ -0,0 +1,119 @@
1 +import SwiftUI
2 +
3 +// Port of ui/ScanCarteScreen.kt. CameraX preview + capture is replaced by the
4 +// VisionKit document camera (perspective-corrected pages); the photo picker
5 +// covers the simulator and imports.
6 +
7 +struct ScanCarteScreen: View {
8 + @ObservedObject var viewModel: ScanCarteViewModel
9 + let onBack: () -> Void
10 + let onContactSaved: (Int64) -> Void
11 +
12 + @State private var showDocumentCamera = false
13 + @State private var showPhotoPicker = false
14 +
15 + var body: some View {
16 + Group {
17 + switch viewModel.state {
18 + case .idle:
19 + captureLauncher
20 + case .processing:
21 + ScanProgressView(message: "Reconnaissance du texte…")
22 + case .draftReady(let draft):
23 + ContactDraftScreen(
24 + draft: draft,
25 + onCardChange: { viewModel.updateDraft($0) },
26 + onSaveToCarnet: { viewModel.saveToCarnet() },
27 + onRetry: { viewModel.reset() },
28 + isEditing: viewModel.isEditing
29 + )
30 + case .error(let message):
31 + errorView(message)
32 + }
33 + }
34 + .toolbar(.hidden, for: .navigationBar)
35 + .onChange(of: viewModel.saveDoneId) { id in
36 + guard let id else { return }
37 + viewModel.consumeSaveDone()
38 + onContactSaved(id)
39 + }
40 + .fullScreenCover(isPresented: $showDocumentCamera) {
41 + DocumentScannerView { outcome in
42 + showDocumentCamera = false
43 + switch outcome {
44 + case .page(let image):
45 + viewModel.processCapture(image, source: .documentCamera)
46 + case .cancelled:
47 + break
48 + case .failed:
49 + viewModel.onCaptureDecodeFailed()
50 + }
51 + }
52 + .ignoresSafeArea()
53 + }
54 + .sheet(isPresented: $showPhotoPicker) {
55 + PhotoPickerView { image in
56 + showPhotoPicker = false
57 + if let image {
58 + viewModel.processCapture(image, source: .photoLibrary)
59 + }
60 + }
61 + }
62 + }
63 +
64 + private var captureLauncher: some View {
65 + VStack(alignment: .leading, spacing: 0) {
66 + ScreenTopBar(title: "Scanner une carte", onBack: onBack)
67 + Text("Cadrez la carte de visite, puis capturez.")
68 + .font(C2VFont.bodyMedium)
69 + .foregroundColor(C2VColor.texteFaible)
70 + .padding(.horizontal, 18)
71 + .padding(.vertical, 8)
72 + Spacer()
73 + VStack(spacing: 10) {
74 + if DocumentScannerView.isSupported {
75 + Button("Capturer") { showDocumentCamera = true }
76 + .buttonStyle(C2VPrimaryButtonStyle())
77 + }
78 + Button("Choisir une photo") { showPhotoPicker = true }
79 + .buttonStyle(
80 + DocumentScannerView.isSupported
81 + ? AnyButtonStyle(C2VOutlineButtonStyle())
82 + : AnyButtonStyle(C2VPrimaryButtonStyle())
83 + )
84 + }
85 + .padding(18)
86 + }
87 + .background(C2VColor.fond)
88 + }
89 +
90 + private func errorView(_ message: String) -> some View {
91 + VStack(spacing: 12) {
92 + Spacer()
93 + Text(message)
94 + .font(C2VFont.bodyLarge)
95 + .foregroundColor(C2VColor.encre)
96 + .multilineTextAlignment(.center)
97 + Button("Reprendre la capture") { viewModel.reset() }
98 + .buttonStyle(C2VPrimaryButtonStyle())
99 + Button("Retour", action: onBack)
100 + .buttonStyle(C2VOutlineButtonStyle())
101 + Spacer()
102 + }
103 + .padding(18)
104 + .background(C2VColor.fond)
105 + }
106 +}
107 +
108 +/// Type-erased button style (conditional primary/outline in the launcher).
109 +struct AnyButtonStyle: ButtonStyle {
110 + private let makeBodyClosure: (Configuration) -> AnyView
111 +
112 + init<S: ButtonStyle>(_ style: S) {
113 + makeBodyClosure = { AnyView(style.makeBody(configuration: $0)) }
114 + }
115 +
116 + func makeBody(configuration: Configuration) -> some View {
117 + makeBodyClosure(configuration)
118 + }
119 +}
A ios/Card2vcf/UI/Scan/ScanCarteViewModel.swift
+119 -0
@@ -0,0 +1,119 @@
1 +import Foundation
2 +import UIKit
3 +
4 +// Port of ui/ScanCarteViewModel.kt. The three Android pipeline stages
5 +// (Scanning/OcrRunning/Structuring) collapse into one `.processing` state:
6 +// `ScanPipeline.process` runs the whole chain in a single call.
7 +
8 +enum ScanUiState {
9 + case idle
10 + case processing
11 + case draftReady(ContactDraftUi)
12 + case error(String)
13 +}
14 +
15 +struct ContactDraftUi {
16 + var card: ContactCard
17 + var rawOcrText: String
18 + var preview: UIImage?
19 +}
20 +
21 +@MainActor
22 +final class ScanCarteViewModel: ObservableObject {
23 + @Published private(set) var state: ScanUiState = .idle
24 + @Published private(set) var saveDoneId: Int64?
25 +
26 + /// Non-nil when rescanning an existing contact (update path).
27 + var editingContactId: Int64?
28 +
29 + var isEditing: Bool { editingContactId != nil }
30 +
31 + private let pipeline: ScanPipeline
32 + private let repository: ContactRepository
33 +
34 + init(
35 + pipeline: ScanPipeline = ScanPipeline(),
36 + repository: ContactRepository = ContactRepository(
37 + dao: Card2vcfDatabase.shared.crmContactDao,
38 + images: ContactImageStore()
39 + )
40 + ) {
41 + self.pipeline = pipeline
42 + self.repository = repository
43 + }
44 +
45 + func processCapture(_ image: UIImage, source: ScanSource) {
46 + state = .processing
47 + Task {
48 + do {
49 + let output = try await pipeline.process(image, source: source)
50 + var card = output.draft
51 + // Rescan keeps the existing notes when the draft has none.
52 + if let editId = editingContactId,
53 + let existing = try await repository.getById(editId),
54 + let notes = existing.notes?.nilIfBlank {
55 + card.note = notes
56 + }
57 + state = .draftReady(
58 + ContactDraftUi(card: card, rawOcrText: output.ocrText, preview: output.preview)
59 + )
60 + } catch let e as ScanException {
61 + state = .error(e.message)
62 + } catch let e as OcrException {
63 + state = .error(e.message)
64 + } catch {
65 + state = .error(error.localizedDescription.nilIfBlank ?? "Capture impossible — réessayez")
66 + }
67 + }
68 + }
69 +
70 + func updateDraft(_ card: ContactCard) {
71 + if case .draftReady(var draft) = state {
72 + draft.card = card
73 + state = .draftReady(draft)
74 + }
75 + }
76 +
77 + /// Saves the draft into the local carnet (new contact), or updates the
78 + /// rescanned contact. Emits the id via `saveDoneId`.
79 + func saveToCarnet() {
80 + guard case .draftReady(let draft) = state else { return }
81 + if let editId = editingContactId {
82 + Task {
83 + do {
84 + let path = draft.preview.flatMap { DraftImageWriter.writeJpeg($0, prefix: "card") }
85 + try await repository.updateFromCard(
86 + id: editId,
87 + card: draft.card,
88 + cardImagePath: path
89 + )
90 + saveDoneId = editId
91 + } catch {
92 + state = .error(error.localizedDescription)
93 + }
94 + }
95 + return
96 + }
97 + Task {
98 + do {
99 + let path = draft.preview.flatMap { DraftImageWriter.writeJpeg($0, prefix: "card") }
100 + let id = try await repository.insertFromCard(draft.card, cardImagePath: path)
101 + saveDoneId = id
102 + } catch {
103 + state = .error(error.localizedDescription)
104 + }
105 + }
106 + }
107 +
108 + func consumeSaveDone() {
109 + saveDoneId = nil
110 + }
111 +
112 + func reset() {
113 + state = .idle
114 + }
115 +
116 + func onCaptureDecodeFailed() {
117 + state = .error("Capture impossible — réessayez")
118 + }
119 +}
A ios/Card2vcf/UI/Scan/SystemContactView.swift
+83 -0
@@ -0,0 +1,83 @@
1 +import Contacts
2 +import ContactsUI
3 +import SwiftUI
4 +
5 +// Port of contact/ContactInsertIntent.kt — maps a ContactCard onto the system
6 +// "new contact" UI (CNContactViewController(forNewContact:)).
7 +
8 +enum ContactInsertMapper {
9 + static func makeContact(_ card: ContactCard) -> CNMutableContact {
10 + let contact = CNMutableContact()
11 + if let first = card.firstName?.nilIfBlank {
12 + contact.givenName = first
13 + if let last = card.lastName?.nilIfBlank {
14 + contact.familyName = last
15 + }
16 + } else if let last = card.lastName?.nilIfBlank {
17 + contact.familyName = last
18 + } else if let full = card.fullName?.nilIfBlank {
19 + // Android passed the display NAME as a single extra.
20 + contact.givenName = full
21 + }
22 + if let company = card.company?.nilIfBlank {
23 + contact.organizationName = company
24 + }
25 + if let job = card.jobTitle?.nilIfBlank {
26 + contact.jobTitle = job
27 + }
28 + if let phone = card.phones.first?.nilIfBlank {
29 + contact.phoneNumbers = [
30 + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: phone))
31 + ]
32 + }
33 + if let email = card.emails.first?.nilIfBlank {
34 + contact.emailAddresses = [
35 + CNLabeledValue(label: CNLabelWork, value: email as NSString)
36 + ]
37 + }
38 + if let site = card.website?.nilIfBlank {
39 + contact.urlAddresses = [
40 + CNLabeledValue(label: CNLabelWork, value: site as NSString)
41 + ]
42 + }
43 + if let address = card.address?.nilIfBlank {
44 + let postal = CNMutablePostalAddress()
45 + postal.street = address
46 + contact.postalAddresses = [CNLabeledValue(label: CNLabelWork, value: postal)]
47 + }
48 + // Android also passed NOTES; `CNContact.note` requires the restricted
49 + // com.apple.developer.contacts.notes entitlement, so it is skipped here.
50 + return contact
51 + }
52 +}
53 +
54 +/// `CNContactViewController(forNewContact:)` wrapped for SwiftUI presentation.
55 +struct SystemContactView: UIViewControllerRepresentable {
56 + let card: ContactCard
57 + let onDismiss: () -> Void
58 +
59 + func makeUIViewController(context: Context) -> UINavigationController {
60 + let controller = CNContactViewController(forNewContact: ContactInsertMapper.makeContact(card))
61 + controller.delegate = context.coordinator
62 + return UINavigationController(rootViewController: controller)
63 + }
64 +
65 + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
66 +
67 + func makeCoordinator() -> Coordinator { Coordinator(onDismiss: onDismiss) }
68 +
69 + final class Coordinator: NSObject, CNContactViewControllerDelegate {
70 + private let onDismiss: () -> Void
71 +
72 + init(onDismiss: @escaping () -> Void) {
73 + self.onDismiss = onDismiss
74 + }
75 +
76 + func contactViewController(
77 + _ viewController: CNContactViewController,
78 + didCompleteWith contact: CNContact?
79 + ) {
80 + onDismiss()
81 + }
82 + }
83 +}
A ios/Card2vcf/UI/Scan/VcfShare.swift
+34 -0
@@ -0,0 +1,34 @@
1 +import CoreTransferable
2 +import Foundation
3 +import UniformTypeIdentifiers
4 +
5 +// Port of contact/VcfShare.kt — slugged file name + temp .vcf file, exposed as
6 +// a `Transferable` so `ShareLink` writes the file lazily on share.
7 +
8 +enum VcfShare {
9 + static func fileName(_ card: ContactCard) -> String {
10 + let base = card.displayName().nilIfBlank ?? "contact"
11 + let slug = KotlinRegex("[^a-z0-9]+")
12 + .replace(base.lowercased(), with: "-")
13 + .trimmingCharacters(in: CharacterSet(charactersIn: "-"))
14 + return "\(slug.nilIfBlank ?? "contact").vcf"
15 + }
16 +
17 + static func writeTempFile(_ card: ContactCard) throws -> URL {
18 + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("vcf", isDirectory: true)
19 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
20 + let url = dir.appendingPathComponent(fileName(card))
21 + try VCardSerializer.toVCard3(card).write(to: url, atomically: true, encoding: .utf8)
22 + return url
23 + }
24 +}
25 +
26 +struct VcfShareFile: Transferable {
27 + let card: ContactCard
28 +
29 + static var transferRepresentation: some TransferRepresentation {
30 + FileRepresentation(exportedContentType: .vCard) { file in
31 + SentTransferredFile(try VcfShare.writeTempFile(file.card))
32 + }
33 + }
34 +}
A ios/Card2vcf/UI/Settings/SettingsScreen.swift
+285 -0
@@ -0,0 +1,285 @@
1 +import EventKit
2 +import SwiftUI
3 +
4 +// Port of ui/settings/SettingsScreen.kt — login form (URL + identifiants,
5 +// « Obtenir la clé » then password retype to decrypt) and, once connected,
6 +// the Calendriers section (permission, « Mes RDV », resources catalogue).
7 +
8 +/// EventKit counterpart of Android's `READ_CALENDAR`/`WRITE_CALENDAR` runtime check.
9 +enum CalendarPermission {
10 + static var granted: Bool {
11 + let status = EKEventStore.authorizationStatus(for: .event)
12 + if #available(iOS 17.0, *) {
13 + return status == .fullAccess
14 + }
15 + return status == .authorized
16 + }
17 +
18 + static func request() async -> Bool {
19 + (try? await EventKitCalendarBridge().requestFullAccess()) ?? false
20 + }
21 +}
22 +
23 +struct SettingsScreen: View {
24 + @ObservedObject var viewModel: SettingsViewModel
25 + let onBack: () -> Void
26 +
27 + @State private var hasCalendarPermission = CalendarPermission.granted
28 +
29 + var body: some View {
30 + VStack(spacing: 0) {
31 + ScreenTopBar(title: "Paramètres", onBack: onBack)
32 + switch viewModel.state {
33 + case .loggedIn(let state):
34 + loggedInContent(state)
35 + case .loggedOut(let state):
36 + loggedOutContent(state)
37 + }
38 + }
39 + .frame(maxHeight: .infinity, alignment: .top)
40 + .background(C2VColor.fond)
41 + .toolbar(.hidden, for: .navigationBar)
42 + .onAppear {
43 + // Kotlin LaunchedEffect(hasCalendarPermission).
44 + hasCalendarPermission = CalendarPermission.granted
45 + viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission)
46 + }
47 + .overlay { dialogs }
48 + }
49 +
50 + // ---- Logged in ----
51 +
52 + private func loggedInContent(_ state: SettingsUiState.LoggedIn) -> some View {
53 + ScrollView {
54 + VStack(alignment: .leading, spacing: 10) {
55 + Text("Connecté comme \(state.userName)")
56 + .font(C2VFont.bodyLarge)
57 + .foregroundColor(C2VColor.ink)
58 + Text(state.baseUrl)
59 + .font(C2VFont.bodyMedium)
60 + .foregroundColor(C2VColor.texteFaible)
61 + Text("Clé API : •••• (configurée)")
62 + .font(C2VFont.bodyMedium)
63 + .foregroundColor(C2VColor.texteFaible)
64 + Button("Déconnecter") { viewModel.disconnect() }
65 + .buttonStyle(C2VTextButtonStyle())
66 +
67 + C2VDivider()
68 +
69 + calendarSection(state)
70 + }
71 + .frame(maxWidth: .infinity, alignment: .leading)
72 + .padding(18)
73 + }
74 + }
75 +
76 + @ViewBuilder
77 + private func calendarSection(_ state: SettingsUiState.LoggedIn) -> some View {
78 + VStack(alignment: .leading, spacing: 8) {
79 + Text("Calendriers")
80 + .font(C2VFont.labelMedium)
81 + .foregroundColor(C2VColor.ink)
82 +
83 + if !hasCalendarPermission {
84 + Text("Autorisez l'accès à l'agenda pour lier vos rendez-vous et vos ressources.")
85 + .font(C2VFont.bodyMedium)
86 + .foregroundColor(C2VColor.texteFaible)
87 + Button("Autoriser l'agenda") {
88 + Task {
89 + hasCalendarPermission = await CalendarPermission.request()
90 + viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission)
91 + }
92 + }
93 + .buttonStyle(C2VOutlineButtonStyle())
94 + } else {
95 + ToggleRow(label: "Mes RDV", checked: state.mesRdvLinked, enabled: true) { enabled in
96 + viewModel.setMesRdvEnabled(enabled)
97 + }
98 +
99 + if state.catalogueLoading {
100 + Text("Chargement des ressources…")
101 + .font(C2VFont.bodyMedium)
102 + .foregroundColor(C2VColor.texteFaible)
103 + } else if let catalogueError = state.catalogueError {
104 + Text(catalogueError)
105 + .font(C2VFont.bodyMedium)
106 + .foregroundColor(C2VColor.ink)
107 + } else {
108 + ressourceGroup(
109 + title: "Salles",
110 + items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_SALLE }
111 + )
112 + ressourceGroup(
113 + title: "Matériel",
114 + items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_MATERIEL }
115 + )
116 + ressourceGroup(
117 + title: "Véhicules",
118 + items: state.ressources.filter { $0.kind == SettingsViewModel.KIND_VEHICULE }
119 + )
120 + }
121 + }
122 + }
123 + }
124 +
125 + @ViewBuilder
126 + private func ressourceGroup(title: String, items: [RessourceToggleState]) -> some View {
127 + if !items.isEmpty {
128 + VStack(alignment: .leading, spacing: 2) {
129 + Text(title)
130 + .font(C2VFont.labelSmall)
131 + .foregroundColor(C2VColor.texteFaible)
132 + ForEach(items, id: \.serverResourceId) { item in
133 + ToggleRow(
134 + label: item.actif ? item.nom : "\(item.nom) — inactive",
135 + checked: item.linked,
136 + enabled: item.actif
137 + ) { enabled in
138 + viewModel.setRessourceEnabled(
139 + kind: item.kind,
140 + serverResourceId: item.serverResourceId,
141 + enabled: enabled
142 + )
143 + }
144 + }
145 + }
146 + }
147 + }
148 +
149 + // ---- Logged out ----
150 +
151 + private func loggedOutContent(_ state: SettingsUiState.LoggedOut) -> some View {
152 + VStack(alignment: .leading, spacing: 12) {
153 + C2VTextField(
154 + label: "",
155 + placeholder: "URL du serveur (https://…)",
156 + text: Binding(get: { state.baseUrl }, set: { viewModel.setBaseUrl($0) })
157 + )
158 + .keyboardType(.URL)
159 + .textInputAutocapitalization(.never)
160 + .autocorrectionDisabled()
161 + C2VTextField(
162 + label: "",
163 + placeholder: "Nom d'utilisateur",
164 + text: Binding(get: { state.userName }, set: { viewModel.setUserName($0) })
165 + )
166 + .textInputAutocapitalization(.never)
167 + .autocorrectionDisabled()
168 + C2VSecureField(
169 + placeholder: "Mot de passe",
170 + text: Binding(get: { state.password }, set: { viewModel.setPassword($0) })
171 + )
172 + ToggleRow(label: "Autoriser HTTP (clair)", checked: state.allowCleartextHttp, enabled: true) { value in
173 + viewModel.setAllowCleartextHttp(value)
174 + }
175 + Text("Uniquement pour le développement, le réseau interne ou une démo. HTTPS reste recommandé.")
176 + .font(C2VFont.bodySmall)
177 + .foregroundColor(C2VColor.texteFaible)
178 + if let error = state.error {
179 + Text(error)
180 + .font(C2VFont.bodyMedium)
181 + .foregroundColor(C2VColor.ink)
182 + }
183 + Button("Obtenir la clé") { viewModel.obtainKey() }
184 + .buttonStyle(C2VPrimaryButtonStyle())
185 + .disabled(state.loading)
186 + }
187 + .padding(18)
188 + }
189 +
190 + // ---- Dialogs ----
191 +
192 + @ViewBuilder
193 + private var dialogs: some View {
194 + switch viewModel.state {
195 + case .loggedOut(let state):
196 + if state.pendingAuth != nil {
197 + retypeDialog(state)
198 + }
199 + case .loggedIn(let state):
200 + if let pending = state.pendingRemoval {
201 + removeBindingDialog(pending)
202 + }
203 + }
204 + }
205 +
206 + private func retypeDialog(_ state: SettingsUiState.LoggedOut) -> some View {
207 + C2VDialog(
208 + title: "Retapez le mot de passe pour déchiffrer",
209 + confirmLabel: "Confirmer",
210 + dismissLabel: "Annuler",
211 + onConfirm: { viewModel.confirmRetype() },
212 + onDismiss: { viewModel.cancelRetype() }
213 + ) {
214 + VStack(alignment: .leading, spacing: 8) {
215 + C2VSecureField(
216 + placeholder: "Mot de passe",
217 + text: Binding(get: { state.retypePassword }, set: { viewModel.setRetypePassword($0) })
218 + )
219 + if let retypeError = state.retypeError {
220 + Text(retypeError)
221 + .font(C2VFont.bodyMedium)
222 + .foregroundColor(C2VColor.ink)
223 + }
224 + }
225 + }
226 + }
227 +
228 + private func removeBindingDialog(_ pending: PendingCalendarRemoval) -> some View {
229 + C2VDialog(
230 + title: "Retirer ce calendrier ?",
231 + confirmLabel: "Retirer",
232 + dismissLabel: "Annuler",
233 + onConfirm: { viewModel.confirmRemoveBinding() },
234 + onDismiss: { viewModel.dismissRemoveBinding() }
235 + ) {
236 + Text("« \(pending.displayName) » ne sera plus synchronisé. Le calendrier reste visible dans l'app Agenda.")
237 + .font(C2VFont.bodyLarge)
238 + .foregroundColor(C2VColor.ink)
239 + }
240 + }
241 +}
242 +
243 +/// Compose `Switch` row: label (greyed when disabled) + trailing ink-tinted toggle.
244 +private struct ToggleRow: View {
245 + let label: String
246 + let checked: Bool
247 + let enabled: Bool
248 + let onCheckedChange: (Bool) -> Void
249 +
250 + var body: some View {
251 + HStack {
252 + Text(label)
253 + .font(C2VFont.bodyLarge)
254 + .foregroundColor(enabled ? C2VColor.ink : C2VColor.texteFaible)
255 + .frame(maxWidth: .infinity, alignment: .leading)
256 + Toggle("", isOn: Binding(get: { checked }, set: { onCheckedChange($0) }))
257 + .labelsHidden()
258 + .tint(C2VColor.ink)
259 + .disabled(!enabled)
260 + }
261 + }
262 +}
263 +
264 +/// Secure variant of `C2VTextField` (Compose `PasswordVisualTransformation`).
265 +private struct C2VSecureField: View {
266 + let placeholder: String
267 + @Binding var text: String
268 +
269 + @FocusState private var focused: Bool
270 +
271 + var body: some View {
272 + SecureField(placeholder, text: $text)
273 + .textFieldStyle(.plain)
274 + .font(C2VFont.bodyLarge)
275 + .foregroundColor(C2VColor.ink)
276 + .tint(C2VColor.ink)
277 + .focused($focused)
278 + .padding(.horizontal, 12)
279 + .padding(.vertical, 10)
280 + .background(C2VColor.canvas)
281 + .overlay(
282 + Rectangle().stroke(focused ? C2VColor.ink : C2VColor.hairline, lineWidth: 1)
283 + )
284 + }
285 +}
A ios/Card2vcf/UI/Settings/SettingsViewModel.swift
+361 -0
@@ -0,0 +1,361 @@
1 +import Foundation
2 +
3 +// Port of ui/settings/SettingsViewModel.kt.
4 +
5 +/// « Ressource » toggle (salle/matériel/véhicule) shown in Paramètres, derived from
6 +/// the server catalogue + local bindings.
7 +struct RessourceToggleState: Equatable {
8 + var kind: String
9 + var serverResourceId: String
10 + var nom: String
11 + var displayName: String
12 + var actif: Bool
13 + var linked: Bool
14 +}
15 +
16 +/// Calendar binding awaiting confirmation before removal (see `SettingsViewModel.confirmRemoveBinding`).
17 +struct PendingCalendarRemoval: Equatable {
18 + var kind: String
19 + var serverResourceId: String?
20 + var displayName: String
21 +}
22 +
23 +/// Paramètres screen state: logged in, or login form (+ pending retype).
24 +enum SettingsUiState {
25 + struct LoggedIn {
26 + var userName: String
27 + var baseUrl: String
28 + var calendarPermissionGranted = false
29 + var mesRdvLinked = false
30 + var ressources: [RessourceToggleState] = []
31 + var catalogueLoading = false
32 + var catalogueError: String? = nil
33 + var pendingRemoval: PendingCalendarRemoval? = nil
34 + }
35 +
36 + struct LoggedOut {
37 + var baseUrl = ""
38 + var userName = ""
39 + var password = ""
40 + /// When true, also accepts `http://` (dev / internal / demo). Default false = HTTPS only.
41 + var allowCleartextHttp = false
42 + var loading = false
43 + var error: String? = nil
44 + var pendingAuth: AuthCleResponse? = nil
45 + var retypePassword = ""
46 + var retypeError: String? = nil
47 + }
48 +
49 + case loggedIn(LoggedIn)
50 + case loggedOut(LoggedOut)
51 +}
52 +
53 +/// API-key login: `obtainKey` calls `/api/auth/cle`, `confirmRetype` decrypts and stores
54 +/// (key persisted, the password itself never is). Once connected, also drives the
55 +/// Calendriers section: permission, « Mes RDV » binding and active resources (server
56 +/// catalogue), with a confirmation dialog before removing a binding.
57 +@MainActor
58 +final class SettingsViewModel: ObservableObject {
59 + static let KIND_SALLE = "salle"
60 + static let KIND_MATERIEL = "materiel"
61 + static let KIND_VEHICULE = "vehicule"
62 + static let MES_RDV_DISPLAY_NAME = "Card2vcf — Mes RDV"
63 +
64 + @Published private(set) var state: SettingsUiState
65 +
66 + private let credentialsStore: SyncCredentialsStore
67 + private let calendarBindingsStore: CalendarBindingsStore
68 + private let calendarBridge: CalendarBridge
69 + private let apiFactory: (String) -> AilianceApi
70 + private let authenticatedApiFactory: (String, String) -> AilianceApi
71 +
72 + init(
73 + credentialsStore: SyncCredentialsStore = SyncCredentialsStore(),
74 + calendarBindingsStore: CalendarBindingsStore = CalendarBindingsStore(),
75 + calendarBridge: CalendarBridge = EventKitCalendarBridge(),
76 + apiFactory: @escaping (String) -> AilianceApi = { baseUrl in
77 + AilianceApiClient(baseUrl: baseUrl)
78 + },
79 + authenticatedApiFactory: @escaping (String, String) -> AilianceApi = { baseUrl, apiKey in
80 + AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
81 + }
82 + ) {
83 + self.credentialsStore = credentialsStore
84 + self.calendarBindingsStore = calendarBindingsStore
85 + self.calendarBridge = calendarBridge
86 + self.apiFactory = apiFactory
87 + self.authenticatedApiFactory = authenticatedApiFactory
88 +
89 + if credentialsStore.isConfigured(),
90 + let baseUrl = credentialsStore.baseUrl,
91 + let userName = credentialsStore.userName {
92 + state = .loggedIn(SettingsUiState.LoggedIn(
93 + userName: userName,
94 + baseUrl: baseUrl,
95 + mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv }
96 + ))
97 + } else {
98 + state = .loggedOut(SettingsUiState.LoggedOut(
99 + baseUrl: credentialsStore.baseUrl ?? "",
100 + allowCleartextHttp: credentialsStore.allowCleartextHttp
101 + ))
102 + }
103 + }
104 +
105 + // ---- Login form ----
106 +
107 + func setBaseUrl(_ value: String) {
108 + updateLoggedOut { $0.baseUrl = value; $0.error = nil }
109 + }
110 +
111 + func setUserName(_ value: String) {
112 + updateLoggedOut { $0.userName = value; $0.error = nil }
113 + }
114 +
115 + func setPassword(_ value: String) {
116 + updateLoggedOut { $0.password = value; $0.error = nil }
117 + }
118 +
119 + func setRetypePassword(_ value: String) {
120 + updateLoggedOut { $0.retypePassword = value; $0.retypeError = nil }
121 + }
122 +
123 + func setAllowCleartextHttp(_ value: Bool) {
124 + credentialsStore.allowCleartextHttp = value
125 + updateLoggedOut { $0.allowCleartextHttp = value; $0.error = nil }
126 + }
127 +
128 + func obtainKey() {
129 + guard case .loggedOut(let s) = state else { return }
130 + let baseUrl = ServerUrlPolicy.normalizeBaseUrl(s.baseUrl)
131 + let userName = s.userName.trimmingCharacters(in: .whitespacesAndNewlines)
132 + if baseUrl.isBlank || userName.isBlank || s.password.isBlank {
133 + updateLoggedOut { $0.error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe" }
134 + return
135 + }
136 + if let err = ServerUrlPolicy.validate(baseUrl, allowCleartext: s.allowCleartextHttp) {
137 + updateLoggedOut { $0.error = err }
138 + return
139 + }
140 + Task {
141 + updateLoggedOut { $0.loading = true; $0.error = nil }
142 + let result = await apiFactory(baseUrl).authCle(nom: userName, motDePasse: s.password)
143 + guard case .loggedOut(var current) = state else { return }
144 + switch result {
145 + case .ok(let value):
146 + current.loading = false
147 + current.password = ""
148 + current.pendingAuth = value
149 + current.error = nil
150 + case .err(let code, let message):
151 + current.loading = false
152 + current.password = ""
153 + current.error = Self.errorMessageFor(code: code, message: message)
154 + }
155 + state = .loggedOut(current)
156 + }
157 + }
158 +
159 + func confirmRetype() {
160 + guard case .loggedOut(let s) = state, let auth = s.pendingAuth else { return }
161 + if s.retypePassword.isBlank {
162 + updateLoggedOut { $0.retypeError = "Mot de passe requis" }
163 + return
164 + }
165 + let retypePassword = s.retypePassword
166 + Task {
167 + // Argon2id KDF (19 MiB, t=2) is CPU-heavy — run it off the main actor.
168 + let apiKey: String? = await Task.detached(priority: .userInitiated) {
169 + try? ApiCleCrypto.decryptApiKey(
170 + password: retypePassword,
171 + saltB64: auth.sel,
172 + cipherB64: auth.cleChiffree
173 + )
174 + }.value
175 + guard case .loggedOut(var current) = state else { return }
176 + guard let apiKey else {
177 + current.retypePassword = ""
178 + current.retypeError = "Mot de passe incorrect"
179 + state = .loggedOut(current)
180 + return
181 + }
182 + let baseUrl = ServerUrlPolicy.normalizeBaseUrl(current.baseUrl)
183 + credentialsStore.save(baseUrl: baseUrl, apiKey: apiKey, userName: auth.nom)
184 + state = .loggedIn(SettingsUiState.LoggedIn(
185 + userName: auth.nom,
186 + baseUrl: baseUrl,
187 + mesRdvLinked: calendarBindingsStore.list().contains { $0.kind == AgendaSyncCoordinator.kindRdv }
188 + ))
189 + }
190 + }
191 +
192 + func cancelRetype() {
193 + updateLoggedOut { $0.pendingAuth = nil; $0.retypePassword = ""; $0.retypeError = nil }
194 + }
195 +
196 + func disconnect() {
197 + credentialsStore.clear()
198 + state = .loggedOut(SettingsUiState.LoggedOut(
199 + allowCleartextHttp: credentialsStore.allowCleartextHttp
200 + ))
201 + }
202 +
203 + // ---- Calendriers ----
204 +
205 + /// Called by the screen after the EventKit access request (Android runtime
206 + /// permission `READ_CALENDAR`/`WRITE_CALENDAR` equivalent).
207 + func onCalendarPermissionChanged(granted: Bool) {
208 + updateLoggedIn { $0.calendarPermissionGranted = granted }
209 + if granted { refreshRessourcesCatalogue() }
210 + }
211 +
212 + func setMesRdvEnabled(_ enabled: Bool) {
213 + guard case .loggedIn = state else { return }
214 + if enabled {
215 + guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: Self.MES_RDV_DISPLAY_NAME) else {
216 + return
217 + }
218 + calendarBindingsStore.upsert(CalendarBinding(
219 + kind: AgendaSyncCoordinator.kindRdv,
220 + displayName: Self.MES_RDV_DISPLAY_NAME,
221 + androidCalendarId: calendarId
222 + ))
223 + updateLoggedIn { $0.mesRdvLinked = true }
224 + } else {
225 + updateLoggedIn {
226 + $0.pendingRemoval = PendingCalendarRemoval(
227 + kind: AgendaSyncCoordinator.kindRdv,
228 + serverResourceId: nil,
229 + displayName: Self.MES_RDV_DISPLAY_NAME
230 + )
231 + }
232 + }
233 + }
234 +
235 + func setRessourceEnabled(kind: String, serverResourceId: String, enabled: Bool) {
236 + guard case .loggedIn(let s) = state else { return }
237 + guard let item = s.ressources.first(where: { $0.kind == kind && $0.serverResourceId == serverResourceId }),
238 + item.actif else {
239 + return
240 + }
241 + if enabled {
242 + guard let calendarId = try? calendarBridge.ensureLocalCalendar(displayName: item.displayName) else {
243 + return
244 + }
245 + calendarBindingsStore.upsert(CalendarBinding(
246 + kind: kind,
247 + serverResourceId: serverResourceId,
248 + displayName: item.displayName,
249 + androidCalendarId: calendarId
250 + ))
251 + updateLoggedIn { current in
252 + current.ressources = current.ressources.map {
253 + $0.withLinked(kind: kind, serverResourceId: serverResourceId, linked: true)
254 + }
255 + }
256 + } else {
257 + updateLoggedIn {
258 + $0.pendingRemoval = PendingCalendarRemoval(
259 + kind: kind,
260 + serverResourceId: serverResourceId,
261 + displayName: item.displayName
262 + )
263 + }
264 + }
265 + }
266 +
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 + func confirmRemoveBinding() {
272 + guard case .loggedIn(let s) = state, let pending = s.pendingRemoval else { return }
273 + calendarBindingsStore.remove(kind: pending.kind, serverResourceId: pending.serverResourceId)
274 + updateLoggedIn { current in
275 + if pending.kind == AgendaSyncCoordinator.kindRdv {
276 + current.mesRdvLinked = false
277 + } else {
278 + current.ressources = current.ressources.map {
279 + $0.withLinked(kind: pending.kind, serverResourceId: pending.serverResourceId, linked: false)
280 + }
281 + }
282 + current.pendingRemoval = nil
283 + }
284 + }
285 +
286 + func dismissRemoveBinding() {
287 + updateLoggedIn { $0.pendingRemoval = nil }
288 + }
289 +
290 + private func refreshRessourcesCatalogue() {
291 + guard case .loggedIn = state else { return }
292 + guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else { return }
293 + updateLoggedIn { $0.catalogueLoading = true; $0.catalogueError = nil }
294 + Task {
295 + let result = await authenticatedApiFactory(baseUrl, apiKey).getRessourcesCatalogue()
296 + switch result {
297 + case .ok(let catalogue):
298 + let bindings = calendarBindingsStore.list()
299 + updateLoggedIn {
300 + $0.ressources = Self.buildRessourceToggles(catalogue: catalogue, bindings: bindings)
301 + $0.catalogueLoading = false
302 + }
303 + case .err(_, let message):
304 + updateLoggedIn { $0.catalogueLoading = false; $0.catalogueError = message }
305 + }
306 + }
307 + }
308 +
309 + static func buildRessourceToggles(
310 + catalogue: RessourcesCatalogueDto,
311 + bindings: [CalendarBinding]
312 + ) -> [RessourceToggleState] {
313 + func toggles(kind: String, items: [RessourceItemDto], label: String) -> [RessourceToggleState] {
314 + items.map { item in
315 + RessourceToggleState(
316 + kind: kind,
317 + serverResourceId: item.id,
318 + nom: item.nom,
319 + displayName: "Card2vcf — \(label) \(item.nom)",
320 + actif: item.actif,
321 + linked: bindings.contains { $0.kind == kind && $0.serverResourceId == item.id }
322 + )
323 + }
324 + }
325 + return toggles(kind: KIND_SALLE, items: catalogue.salles, label: "Salle")
326 + + toggles(kind: KIND_MATERIEL, items: catalogue.materiels, label: "Matériel")
327 + + toggles(kind: KIND_VEHICULE, items: catalogue.vehicules, label: "Véhicule")
328 + }
329 +
330 + // ---- Helpers ----
331 +
332 + private func updateLoggedOut(_ transform: (inout SettingsUiState.LoggedOut) -> Void) {
333 + guard case .loggedOut(var s) = state else { return }
334 + transform(&s)
335 + state = .loggedOut(s)
336 + }
337 +
338 + private func updateLoggedIn(_ transform: (inout SettingsUiState.LoggedIn) -> Void) {
339 + guard case .loggedIn(var s) = state else { return }
340 + transform(&s)
341 + state = .loggedIn(s)
342 + }
343 +
344 + static func errorMessageFor(code: Int, message: String) -> String {
345 + switch code {
346 + case 401: return "Identifiants invalides"
347 + case 404: return "Aucune clé API pour cet utilisateur"
348 + case -1: return "Erreur réseau : \(message)"
349 + default: return message
350 + }
351 + }
352 +}
353 +
354 +private extension RessourceToggleState {
355 + func withLinked(kind: String, serverResourceId: String?, linked: Bool) -> RessourceToggleState {
356 + guard self.kind == kind, self.serverResourceId == serverResourceId else { return self }
357 + var copy = self
358 + copy.linked = linked
359 + return copy
360 + }
361 +}
A ios/Card2vcf/UI/Support.swift
+184 -0
@@ -0,0 +1,184 @@
1 +import SwiftUI
2 +import UIKit
3 +
4 +// Shared UI helpers: display names, top bars, path-based images, external
5 +// links and draft image persistence.
6 +
7 +// Kotlin `contactDisplayName` (CarnetScreen/ContactPagerScreen).
8 +func contactDisplayName(_ c: CrmContactEntity) -> String {
9 + if let full = c.fullName?.nilIfBlank { return full }
10 + let composed = [c.firstName, c.lastName].compactMap { $0 }
11 + .joined(separator: " ")
12 + .trimmingCharacters(in: .whitespaces)
13 + if !composed.isEmpty { return composed }
14 + if let company = c.company?.nilIfBlank { return company }
15 + return "—"
16 +}
17 +
18 +// Kotlin `contactSubtitle` (CarnetScreen).
19 +func contactSubtitle(_ c: CrmContactEntity) -> String? {
20 + let name: String?
21 + if let full = c.fullName?.nilIfBlank {
22 + name = full
23 + } else {
24 + let composed = [c.firstName, c.lastName].compactMap { $0 }
25 + .joined(separator: " ")
26 + .trimmingCharacters(in: .whitespaces)
27 + name = composed.isEmpty ? nil : composed
28 + }
29 + if name != nil {
30 + return c.company?.nilIfBlank
31 + }
32 + return c.jobTitle?.nilIfBlank
33 +}
34 +
35 +/// Custom top bar mirroring the Android screens' own header rows
36 +/// (system navigation bars are hidden on ported screens).
37 +struct ScreenTopBar<Trailing: View>: View {
38 + let title: String
39 + var centered: Bool = true
40 + let onBack: () -> Void
41 + @ViewBuilder var trailing: () -> Trailing
42 +
43 + init(
44 + title: String,
45 + centered: Bool = true,
46 + onBack: @escaping () -> Void,
47 + @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }
48 + ) {
49 + self.title = title
50 + self.centered = centered
51 + self.onBack = onBack
52 + self.trailing = trailing
53 + }
54 +
55 + var body: some View {
56 + ZStack {
57 + HStack(spacing: 4) {
58 + Button(action: onBack) {
59 + Image(systemName: "arrow.left")
60 + .foregroundColor(C2VColor.ink)
61 + .frame(width: 44, height: 44)
62 + }
63 + .accessibilityLabel("Retour")
64 + if !centered {
65 + Text(title)
66 + .font(C2VFont.titleMedium)
67 + .foregroundColor(C2VColor.encre)
68 + }
69 + Spacer()
70 + trailing()
71 + }
72 + if centered {
73 + Text(title)
74 + .font(C2VFont.titleMedium)
75 + .foregroundColor(C2VColor.encre)
76 + }
77 + }
78 + .padding(.horizontal, 4)
79 + .padding(.vertical, 4)
80 + .background(C2VColor.surface)
81 + }
82 +}
83 +
84 +/// Kotlin `PathImage` — renders nothing when the file is missing.
85 +struct PathImage: View {
86 + let path: String?
87 + var contentMode: ContentMode = .fill
88 + /// Bumps identity when the underlying file changed (Kotlin `revision`).
89 + var revision: Int64 = 0
90 +
91 + var body: some View {
92 + if let path = path?.nilIfBlank, let ui = UIImage(contentsOfFile: path) {
93 + Image(uiImage: ui)
94 + .resizable()
95 + .aspectRatio(contentMode: contentMode)
96 + .id("\(path)#\(revision)")
97 + }
98 + }
99 +}
100 +
101 +/// Android `ACTION_DIAL` / `ACTION_SENDTO` equivalents.
102 +enum ExternalLinks {
103 + static func dial(_ phone: String) {
104 + let tel = phone.filter { $0.isNumber || $0 == "+" }
105 + guard !tel.isEmpty, let url = URL(string: "tel:\(tel)") else { return }
106 + UIApplication.shared.open(url)
107 + }
108 +
109 + static func email(_ address: String) {
110 + let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? address
111 + guard let url = URL(string: "mailto:\(encoded)") else { return }
112 + UIApplication.shared.open(url)
113 + }
114 +
115 + static func web(_ site: String) {
116 + var raw = site.trimmingCharacters(in: .whitespaces)
117 + if !raw.lowercased().hasPrefix("http://") && !raw.lowercased().hasPrefix("https://") {
118 + raw = "https://\(raw)"
119 + }
120 + guard let url = URL(string: raw) else { return }
121 + UIApplication.shared.open(url)
122 + }
123 +}
124 +
125 +/// Persists UI-captured images as JPEG files; the repository stores paths only
126 +/// (Android saved Bitmaps through `ContactImageStore`).
127 +enum DraftImageWriter {
128 + static func writeJpeg(_ image: UIImage, prefix: String) -> String? {
129 + guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
130 + let fm = FileManager.default
131 + guard
132 + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
133 + else { return nil }
134 + let dir = base.appendingPathComponent("images", isDirectory: true)
135 + do {
136 + try fm.createDirectory(at: dir, withIntermediateDirectories: true)
137 + let url = dir.appendingPathComponent("\(prefix)-\(UUID().uuidString).jpg")
138 + try data.write(to: url, options: .atomic)
139 + return url.path
140 + } catch {
141 + return nil
142 + }
143 + }
144 +}
145 +
146 +/// `UIImagePickerController` camera wrapper for the profile-photo capture
147 +/// (Android `ActivityResultContracts.TakePicture`).
148 +struct CameraPickerView: UIViewControllerRepresentable {
149 + let completion: (UIImage?) -> Void
150 +
151 + static var isAvailable: Bool {
152 + UIImagePickerController.isSourceTypeAvailable(.camera)
153 + }
154 +
155 + func makeUIViewController(context: Context) -> UIImagePickerController {
156 + let controller = UIImagePickerController()
157 + controller.sourceType = .camera
158 + controller.delegate = context.coordinator
159 + return controller
160 + }
161 +
162 + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
163 +
164 + func makeCoordinator() -> Coordinator { Coordinator(completion: completion) }
165 +
166 + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
167 + private let completion: (UIImage?) -> Void
168 +
169 + init(completion: @escaping (UIImage?) -> Void) {
170 + self.completion = completion
171 + }
172 +
173 + func imagePickerController(
174 + _ picker: UIImagePickerController,
175 + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
176 + ) {
177 + completion(info[.originalImage] as? UIImage)
178 + }
179 +
180 + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
181 + completion(nil)
182 + }
183 + }
184 +}
A ios/Card2vcf/UI/Sync/SyncBanner.swift
+96 -0
@@ -0,0 +1,96 @@
1 +import SwiftUI
2 +
3 +// Port of ui/sync/SyncBanner.kt.
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.
8 +struct SyncBanner: View {
9 + let pendingChanges: Int?
10 + let localAhead: Int
11 + let syncing: Bool
12 + let error: String?
13 + let onSyncClick: () -> Void
14 +
15 + var body: some View {
16 + let remoteLabel: String? = {
17 + if let error { return error }
18 + if let pendingChanges, pendingChanges > 0 {
19 + return "\(pendingChanges) changement(s) sur le serveur"
20 + }
21 + return nil
22 + }()
23 + if remoteLabel != nil || localAhead > 0 {
24 + VStack(spacing: 0) {
25 + if let remoteLabel {
26 + SyncBannerRow(label: remoteLabel, syncing: syncing, onSyncClick: onSyncClick)
27 + }
28 + if localAhead > 0 {
29 + SyncBannerRow(
30 + label: "Calendrier local en avance sur le serveur",
31 + syncing: syncing,
32 + onSyncClick: onSyncClick
33 + )
34 + }
35 + }
36 + }
37 + }
38 +}
39 +
40 +private struct SyncBannerRow: View {
41 + let label: String
42 + let syncing: Bool
43 + let onSyncClick: () -> Void
44 +
45 + var body: some View {
46 + VStack(spacing: 0) {
47 + HStack {
48 + Text(label)
49 + .font(C2VFont.bodyLarge)
50 + .foregroundColor(C2VColor.ink)
51 + .frame(maxWidth: .infinity, alignment: .leading)
52 + Button(syncing ? "Synchronisation…" : "Synchroniser", action: onSyncClick)
53 + .buttonStyle(C2VTextButtonStyle())
54 + .disabled(syncing)
55 + }
56 + .padding(.vertical, 8)
57 + C2VDivider()
58 + }
59 + }
60 +}
61 +
62 +/// « Annuler ma réservation ? » dialog shown after a `SyncChromeViewModel.syncNow`
63 +/// hit a conflict (409). Kotlin `SyncConflictDialog`, applied as a modifier.
64 +private struct SyncConflictDialogModifier: ViewModifier {
65 + let conflict: AgendaConflict?
66 + let onConfirm: () -> Void
67 + let onDismiss: () -> Void
68 +
69 + func body(content: Content) -> some View {
70 + content.overlay {
71 + if conflict != nil {
72 + C2VDialog(
73 + title: "Annuler ma réservation ?",
74 + confirmLabel: "Oui, annuler",
75 + dismissLabel: "Non",
76 + onConfirm: onConfirm,
77 + onDismiss: onDismiss
78 + ) {
79 + Text("Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l'annuler ?")
80 + .font(C2VFont.bodyLarge)
81 + .foregroundColor(C2VColor.ink)
82 + }
83 + }
84 + }
85 + }
86 +}
87 +
88 +extension View {
89 + func syncConflictDialog(
90 + conflict: AgendaConflict?,
91 + onConfirm: @escaping () -> Void,
92 + onDismiss: @escaping () -> Void
93 + ) -> some View {
94 + modifier(SyncConflictDialogModifier(conflict: conflict, onConfirm: onConfirm, onDismiss: onDismiss))
95 + }
96 +}
A ios/Card2vcf/UI/Sync/SyncChromeViewModel.swift
+139 -0
@@ -0,0 +1,139 @@
1 +import Foundation
2 +
3 +// Port of ui/sync/SyncChromeViewModel.kt.
4 +
5 +/// Sync banner state: server-side changes + local calendar ahead on screen open
6 +/// (`refreshStatus`, no auto-pull), manual sync on demand (`syncNow`: push then pull).
7 +/// After `syncNow`, a conflicting reservation (409) triggers the « Annuler ma
8 +/// réservation ? » dialog (`conflictPending`) — one decision at a time when
9 +/// several conflicts are queued.
10 +@MainActor
11 +final class SyncChromeViewModel: ObservableObject {
12 + @Published private(set) var configured: Bool
13 + @Published private(set) var pendingRemoteChanges: Int?
14 + @Published private(set) var localCalendarAhead = 0
15 + @Published private(set) var syncing = false
16 + @Published private(set) var error: String?
17 + @Published private(set) var conflictPending: AgendaConflict?
18 +
19 + private var pendingConflicts: [AgendaConflict] = []
20 +
21 + private let credentialsStore: SyncCredentialsStore
22 + private let calendarBindingsStore: CalendarBindingsStore
23 + private let calendarBridge: CalendarBridge?
24 + private let engineFactory: () -> SyncEngine?
25 +
26 + init(
27 + credentialsStore: SyncCredentialsStore,
28 + calendarBindingsStore: CalendarBindingsStore,
29 + calendarBridge: CalendarBridge?,
30 + engineFactory: @escaping () -> SyncEngine?
31 + ) {
32 + self.credentialsStore = credentialsStore
33 + self.calendarBindingsStore = calendarBindingsStore
34 + self.calendarBridge = calendarBridge
35 + self.engineFactory = engineFactory
36 + self.configured = credentialsStore.isConfigured()
37 + }
38 +
39 + /// Production wiring (Kotlin `Card2vcfNavHost` remember block): engine built
40 + /// per call from the stored credentials, `nil` while not configured.
41 + convenience init() {
42 + let credentialsStore = SyncCredentialsStore()
43 + self.init(
44 + credentialsStore: credentialsStore,
45 + calendarBindingsStore: CalendarBindingsStore(),
46 + calendarBridge: EventKitCalendarBridge(),
47 + engineFactory: {
48 + guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else {
49 + return nil
50 + }
51 + return SyncEngine(
52 + api: AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey),
53 + db: Card2vcfDatabase.shared,
54 + imageStore: ContactImageStore()
55 + )
56 + }
57 + )
58 + }
59 +
60 + /// Called on screen open: `checkStatus` (no pull) + local-ahead count.
61 + func refreshStatus() {
62 + configured = credentialsStore.isConfigured()
63 + guard let engine = engineFactory() else { return }
64 + Task {
65 + let bindings = calendarBindingsStore.list()
66 + let ressourcesQuery = AgendaSyncCoordinator.ressourcesQuery(bindings)
67 + do {
68 + let result = try await engine.checkStatus(ressourcesQuery: ressourcesQuery)
69 + pendingRemoteChanges = result.pendingRemoteChanges
70 + error = result.error
71 + } catch {
72 + self.error = error.localizedDescription
73 + }
74 + await refreshLocalAhead(engine: engine, bindings: bindings)
75 + }
76 + }
77 +
78 + /// Manual sync: push queued ops then pull, refresh the banner counters after.
79 + func syncNow() {
80 + guard let engine = engineFactory() else { return }
81 + Task {
82 + syncing = true
83 + error = nil
84 + let bindings = calendarBindingsStore.list()
85 + do {
86 + let result = try await engine.syncNow(bindings: bindings, bridge: calendarBridge)
87 + if result.success {
88 + setPendingConflicts(result.conflicts)
89 + let status = try await engine.checkStatus(
90 + ressourcesQuery: AgendaSyncCoordinator.ressourcesQuery(bindings)
91 + )
92 + pendingRemoteChanges = status.pendingRemoteChanges
93 + error = status.error
94 + } else {
95 + error = result.error
96 + }
97 + } catch {
98 + self.error = error.localizedDescription
99 + }
100 + await refreshLocalAhead(engine: engine, bindings: bindings)
101 + syncing = false
102 + }
103 + }
104 +
105 + /// Conflict dialog, « Oui » : abandons the local reservation (entity + SyncOp +
106 + /// calendar event), then moves to the next conflict.
107 + func confirmConflictCancellation() {
108 + guard let conflict = conflictPending, let engine = engineFactory() else { return }
109 + Task {
110 + try? await engine.abandonLocalReservation(localId: conflict.localReservationId, bridge: calendarBridge)
111 + advanceConflictQueue()
112 + await refreshLocalAhead(engine: engine, bindings: calendarBindingsStore.list())
113 + }
114 + }
115 +
116 + /// Conflict dialog, « Non » : leaves the reservation untouched (stays
117 + /// `conflictPending` server-side, arbitration happens on the web server).
118 + func dismissConflict() {
119 + advanceConflictQueue()
120 + }
121 +
122 + private func refreshLocalAhead(engine: SyncEngine, bindings: [CalendarBinding]) async {
123 + if let count = try? await engine.localAheadCount(bindings: bindings, bridge: calendarBridge) {
124 + localCalendarAhead = count
125 + }
126 + }
127 +
128 + private func setPendingConflicts(_ conflicts: [AgendaConflict]) {
129 + pendingConflicts = conflicts
130 + conflictPending = pendingConflicts.first
131 + }
132 +
133 + private func advanceConflictQueue() {
134 + if !pendingConflicts.isEmpty {
135 + pendingConflicts.removeFirst()
136 + }
137 + conflictPending = pendingConflicts.first
138 + }
139 +}
A ios/Card2vcf/UI/Sync/SyncUserContext.swift
+8 -0
@@ -0,0 +1,8 @@
1 +import Foundation
2 +
3 +/// Connected-user identity for screens that need the login (kanban « Mes tâches »
4 +/// filter, compte-rendu author). Reads `SyncCredentialsStore.userName`, exactly as
5 +/// the Kotlin screens read it from the credentials store; empty when logged out.
6 +enum SyncUserContext {
7 + static var userName: String { SyncCredentialsStore().userName ?? "" }
8 +}
A ios/Card2vcf/UI/Theme.swift
+180 -0
@@ -0,0 +1,180 @@
1 +import SwiftUI
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.
6 +
7 +enum C2VColor {
8 + static let ink = Color(hexRGB: 0x000000)
9 + static let inkSoft = Color(hexRGB: 0x1A1A1A)
10 + static let body = Color(hexRGB: 0x757575)
11 + static let canvas = Color(hexRGB: 0xFFFFFF)
12 + static let canvasSoft = Color(hexRGB: 0xF5F5F5)
13 + static let hairline = Color(hexRGB: 0xE0E0E0)
14 + static let link = Color(hexRGB: 0x057DBC)
15 + static let onPrimary = Color(hexRGB: 0xFFFFFF)
16 +
17 + // Historical aliases used by the migrated screens (Kotlin: Encre/Fond/…).
18 + static let encre = ink
19 + static let fond = canvas
20 + static let surface = canvas
21 + static let bordure = hairline
22 + static let texteFaible = body
23 +}
24 +
25 +private extension Color {
26 + init(hexRGB: UInt32) {
27 + self.init(
28 + red: Double((hexRGB >> 16) & 0xFF) / 255.0,
29 + green: Double((hexRGB >> 8) & 0xFF) / 255.0,
30 + blue: Double(hexRGB & 0xFF) / 255.0
31 + )
32 + }
33 +}
34 +
35 +// Android bundled Playfair (display serif), Lora (body serif) and Manrope
36 +// (sans labels). No fonts ship in the iOS target, so the system serif/sans
37 +// designs carry the same editorial split: serif for narrative, sans for labels.
38 +enum C2VFont {
39 + static let displayLarge = Font.system(size: 40, weight: .regular, design: .serif)
40 + static let displayMedium = Font.system(size: 32, weight: .regular, design: .serif)
41 + static let headlineMedium = Font.system(size: 26, weight: .regular, design: .serif)
42 + static let titleLarge = Font.system(size: 22, weight: .regular, design: .serif)
43 + static let titleMedium = Font.system(size: 16, weight: .bold)
44 + static let bodyLarge = Font.system(size: 16, weight: .regular, design: .serif)
45 + static let bodyMedium = Font.system(size: 14, weight: .regular)
46 + static let bodySmall = Font.system(size: 12, weight: .regular)
47 + static let labelLarge = Font.system(size: 16, weight: .bold)
48 + static let labelMedium = Font.system(size: 14, weight: .bold)
49 + static let labelSmall = Font.system(size: 12, weight: .bold)
50 +}
51 +
52 +/// design.md `button-primary`: square black CTA, white label.
53 +struct C2VPrimaryButtonStyle: ButtonStyle {
54 + func makeBody(configuration: Configuration) -> some View {
55 + configuration.label
56 + .font(C2VFont.labelLarge)
57 + .foregroundColor(C2VColor.onPrimary)
58 + .frame(maxWidth: .infinity)
59 + .padding(.vertical, 12)
60 + .padding(.horizontal, 20)
61 + .background(C2VColor.ink.opacity(configuration.isPressed ? 0.8 : 1))
62 + }
63 +}
64 +
65 +/// design.md `button-outline`: white square with 1 px border, ink label.
66 +struct C2VOutlineButtonStyle: ButtonStyle {
67 + func makeBody(configuration: Configuration) -> some View {
68 + configuration.label
69 + .font(C2VFont.labelLarge)
70 + .foregroundColor(C2VColor.ink)
71 + .frame(maxWidth: .infinity)
72 + .padding(.vertical, 12)
73 + .padding(.horizontal, 20)
74 + .background(configuration.isPressed ? C2VColor.canvasSoft : C2VColor.canvas)
75 + .overlay(Rectangle().stroke(C2VColor.hairline, lineWidth: 1))
76 + }
77 +}
78 +
79 +/// Compose `TextButton` with square shape — bare ink label.
80 +struct C2VTextButtonStyle: ButtonStyle {
81 + var color: Color = C2VColor.ink
82 +
83 + func makeBody(configuration: Configuration) -> some View {
84 + configuration.label
85 + .font(C2VFont.labelMedium)
86 + .foregroundColor(color.opacity(configuration.isPressed ? 0.6 : 1))
87 + .padding(.vertical, 8)
88 + .padding(.horizontal, 8)
89 + }
90 +}
91 +
92 +/// 1 px hairline divider (design.md `hairline-divider`).
93 +struct C2VDivider: View {
94 + var body: some View {
95 + Rectangle()
96 + .fill(C2VColor.hairline)
97 + .frame(height: 1)
98 + }
99 +}
100 +
101 +/// Compose `AlertDialog` with square shape: dimmed backdrop (tap = dismiss request),
102 +/// square card on `Fond` with title, content and trailing text buttons. Stays
103 +/// presented until the caller clears its trigger state, so inline errors survive
104 +/// a failed confirm (system alerts always dismiss on any button).
105 +struct C2VDialog<DialogContent: View>: View {
106 + let title: String
107 + let confirmLabel: String
108 + let dismissLabel: String
109 + let onConfirm: () -> Void
110 + let onDismiss: () -> Void
111 + @ViewBuilder var content: () -> DialogContent
112 +
113 + var body: some View {
114 + ZStack {
115 + Color.black.opacity(0.35)
116 + .ignoresSafeArea()
117 + .onTapGesture(perform: onDismiss)
118 + VStack(alignment: .leading, spacing: 12) {
119 + Text(title)
120 + .font(C2VFont.titleMedium)
121 + .foregroundColor(C2VColor.ink)
122 + content()
123 + HStack(spacing: 4) {
124 + Spacer()
125 + Button(dismissLabel, action: onDismiss)
126 + .buttonStyle(C2VTextButtonStyle())
127 + Button(confirmLabel, action: onConfirm)
128 + .buttonStyle(C2VTextButtonStyle())
129 + }
130 + }
131 + .padding(20)
132 + .background(C2VColor.fond)
133 + .overlay(Rectangle().stroke(C2VColor.hairline, lineWidth: 1))
134 + .padding(.horizontal, 32)
135 + }
136 + }
137 +}
138 +
139 +/// Square outlined text field, hairline border, ink border when focused
140 +/// (Compose `OutlinedTextField` with the Card2vcf color set).
141 +struct C2VTextField: View {
142 + let label: String
143 + var placeholder: String = ""
144 + @Binding var text: String
145 + var minLines: Int = 1
146 +
147 + @FocusState private var focused: Bool
148 +
149 + var body: some View {
150 + VStack(alignment: .leading, spacing: 4) {
151 + if !label.isEmpty {
152 + Text(label)
153 + .font(C2VFont.labelSmall)
154 + .foregroundColor(C2VColor.body)
155 + }
156 + field
157 + .textFieldStyle(.plain)
158 + .font(C2VFont.bodyLarge)
159 + .foregroundColor(C2VColor.ink)
160 + .tint(C2VColor.ink)
161 + .focused($focused)
162 + .padding(.horizontal, 12)
163 + .padding(.vertical, 10)
164 + .background(C2VColor.canvas)
165 + .overlay(
166 + Rectangle().stroke(focused ? C2VColor.ink : C2VColor.hairline, lineWidth: 1)
167 + )
168 + }
169 + }
170 +
171 + @ViewBuilder
172 + private var field: some View {
173 + if minLines > 1 {
174 + TextField(placeholder, text: $text, axis: .vertical)
175 + .lineLimit(minLines...)
176 + } else {
177 + TextField(placeholder, text: $text)
178 + }
179 + }
180 +}
A ios/Card2vcfTests/AgendaSyncEngineTest.swift
+476 -0
@@ -0,0 +1,476 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Task M5 (Android): LWW RDV, reservation 409 conflict, abandon, `ressourcesQuery`,
5 +/// calendar↔store bridge.
6 +final class AgendaSyncEngineTest: XCTestCase {
7 + private var db: InMemoryCrmDatabase!
8 + private var api: FakeAilianceApi!
9 + private var engine: SyncEngine!
10 +
11 + override func setUp() {
12 + super.setUp()
13 + db = InMemoryCrmDatabase()
14 + api = FakeAilianceApi()
15 + engine = SyncEngine(api: api, db: db)
16 + }
17 +
18 + private func epoch(_ iso: String) -> Int64 {
19 + parseIsoToEpochMs(iso)
20 + }
21 +
22 + // ---- LWW RDV ----
23 +
24 + func testSyncNow_rdvPullNewerServerOverwritesCleanLocal() async throws {
25 + var local = RdvEntity()
26 + local.serverId = "rdv1"
27 + local.titre = "Ancien titre"
28 + local.updatedAt = epoch("2026-07-20T10:00:00Z")
29 + _ = try await db.rdvDao.upsert(local)
30 + api.pullResult = .ok(
31 + SyncPullResponse(
32 + serverTime: "2026-07-22T12:00:00Z",
33 + rdv: [
34 + RendezVousDto(
35 + id: "rdv1",
36 + titre: "Nouveau titre serveur",
37 + debut: "2026-07-22T09:00:00Z",
38 + fin: "2026-07-22T10:00:00Z",
39 + creeLe: "2026-07-20T10:00:00Z",
40 + misAJourLe: "2026-07-22T10:00:00Z"
41 + ),
42 + ]
43 + )
44 + )
45 +
46 + _ = try await engine.syncNow()
47 +
48 + let updated = try await db.rdvDao.getByServerId("rdv1")
49 + XCTAssertEqual("Nouveau titre serveur", updated?.titre)
50 + }
51 +
52 + func testSyncNow_rdvPullDirtyLocalNewerKeepsLocal() async throws {
53 + var local = RdvEntity()
54 + local.serverId = "rdv2"
55 + local.titre = "Titre local en avance"
56 + local.updatedAt = epoch("2026-07-22T10:00:00Z")
57 + local.dirtyLocal = true
58 + _ = try await db.rdvDao.upsert(local)
59 + api.pullResult = .ok(
60 + SyncPullResponse(
61 + serverTime: "2026-07-22T12:00:00Z",
62 + rdv: [
63 + RendezVousDto(
64 + id: "rdv2",
65 + titre: "Titre serveur périmé",
66 + debut: "2026-07-19T09:00:00Z",
67 + fin: "2026-07-19T10:00:00Z",
68 + creeLe: "2026-07-18T10:00:00Z",
69 + misAJourLe: "2026-07-20T10:00:00Z"
70 + ),
71 + ]
72 + )
73 + )
74 +
75 + _ = try await engine.syncNow()
76 +
77 + let kept = try await db.rdvDao.getByServerId("rdv2")
78 + XCTAssertEqual("Titre local en avance", kept?.titre)
79 + XCTAssertEqual(true, kept?.dirtyLocal)
80 + }
81 +
82 + // ---- Conflit 409 réservation ----
83 +
84 + func testSyncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() async throws {
85 + var reservation = ReservationEntity()
86 + reservation.cibleType = "salle"
87 + reservation.cibleId = "salle-1"
88 + reservation.motif = "Réunion"
89 + reservation.dirtyLocal = true
90 + let localId = try await db.reservationDao.upsert(reservation)
91 + var op = SyncOpEntity(entityType: "reservation", op: "create")
92 + op.payloadJson = #"{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}"#
93 + op.localId = localId
94 + op.createdAt = 1
95 + _ = try await db.syncOpDao.insert(op)
96 + api.createReservationResult = .err(code: 409, message: "Chevauchement avec une autre réservation")
97 +
98 + let result = try await engine.syncNow()
99 +
100 + XCTAssertFalse(result.conflicts.isEmpty)
101 + XCTAssertEqual(localId, result.conflicts.first?.localReservationId)
102 + let stored = try await db.reservationDao.getByLocalId(localId)
103 + XCTAssertEqual(true, stored?.conflictPending)
104 + let pending = try await db.syncOpDao.listAll()
105 + XCTAssertEqual(1, pending.count)
106 + XCTAssertEqual(1, api.createReservationCalls.count)
107 + XCTAssertEqual("salle", api.createReservationCalls[0].0)
108 + XCTAssertEqual("salle-1", api.createReservationCalls[0].1)
109 + }
110 +
111 + // ---- Abandon ----
112 +
113 + func testAbandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() async throws {
114 + var reservation = ReservationEntity()
115 + reservation.cibleType = "salle"
116 + reservation.cibleId = "salle-1"
117 + reservation.motif = "Réunion"
118 + reservation.conflictPending = true
119 + reservation.calendarEventId = 42
120 + let localId = try await db.reservationDao.upsert(reservation)
121 + var op = SyncOpEntity(entityType: "reservation", op: "create")
122 + op.localId = localId
123 + op.createdAt = 1
124 + _ = try await db.syncOpDao.insert(op)
125 + let bridge = FakeCalendarBridge()
126 +
127 + try await engine.abandonLocalReservation(localId: localId, bridge: bridge)
128 +
129 + let stored = try await db.reservationDao.getByLocalId(localId)
130 + XCTAssertNil(stored)
131 + let pending = try await db.syncOpDao.listAll()
132 + XCTAssertTrue(pending.isEmpty)
133 + XCTAssertTrue(bridge.deletedEventIds.contains(42))
134 + }
135 +
136 + // ---- ressourcesQuery ----
137 +
138 + func testRessourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() {
139 + let coordinator = AgendaSyncCoordinator(db: db)
140 + let bindings = [
141 + CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: 1),
142 + CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: 2),
143 + CalendarBinding(kind: "materiel", serverResourceId: "m1", displayName: "Matériel B", androidCalendarId: 3),
144 + CalendarBinding(kind: "vehicule", serverResourceId: nil, displayName: "Sans id serveur", androidCalendarId: 4),
145 + ]
146 +
147 + XCTAssertEqual("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings))
148 + }
149 +
150 + func testRessourcesQuery_emptyBindingsReturnsNil() {
151 + let coordinator = AgendaSyncCoordinator(db: db)
152 +
153 + XCTAssertNil(coordinator.ressourcesQuery([]))
154 + }
155 +
156 + func testSyncNow_pullUsesRessourcesQueryDerivedFromBindings() async throws {
157 + let bridge = FakeCalendarBridge()
158 + let rdvCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
159 + let salleCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
160 + let bindings = [
161 + CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: rdvCalendarId),
162 + CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: salleCalendarId),
163 + ]
164 +
165 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
166 +
167 + XCTAssertEqual("salle:s1", api.pullQueries.last ?? nil)
168 + }
169 +
170 + func testCheckStatus_forwardsRessourcesQueryToApi() async throws {
171 + _ = try await engine.checkStatus(ressourcesQuery: "salle:s1")
172 +
173 + XCTAssertEqual("salle:s1", api.statusQueries.last ?? nil)
174 + }
175 +
176 + func testSyncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() async throws {
177 + _ = try await engine.syncNow()
178 +
179 + XCTAssertNil(api.pullQueries.last ?? nil)
180 + }
181 +
182 + // ---- Agenda -> Room / Room -> Agenda ----
183 +
184 + func testSyncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() async throws {
185 + let bridge = FakeCalendarBridge()
186 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
187 + bridge.seedEvent(
188 + calendarId: calendarId,
189 + snap: CalendarEventSnapshot(title: "RDV client", debutMs: 1_000, finMs: 2_000, descriptionBody: "notes")
190 + )
191 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
192 +
193 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
194 +
195 + let stored = try await db.rdvDao.listAll()
196 + XCTAssertEqual(1, stored.count)
197 + XCTAssertEqual("RDV client", stored.first?.titre)
198 + XCTAssertEqual(1, api.createRdvCalls.count)
199 + let pending = try await db.syncOpDao.listAll()
200 + XCTAssertTrue(pending.allSatisfy { $0.entityType != "rdv" })
201 + }
202 +
203 + func testSyncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() async throws {
204 + let bridge = FakeCalendarBridge()
205 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
206 + var rdv = RdvEntity()
207 + rdv.serverId = "rdv-9"
208 + rdv.titre = "RDV serveur"
209 + rdv.debutMs = 5_000
210 + rdv.finMs = 6_000
211 + rdv.updatedAt = 1
212 + let localId = try await db.rdvDao.upsert(rdv)
213 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
214 +
215 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
216 +
217 + let updated = try await db.rdvDao.getByLocalId(localId)
218 + XCTAssertNotNil(updated?.calendarEventId)
219 + let events = try bridge.listEvents(calendarId: calendarId)
220 + XCTAssertEqual(1, events.count)
221 + XCTAssertEqual("RDV serveur", events.first?.title)
222 + }
223 +
224 + // ---- Fix HIGH : roomToAgenda deletes orphan events ----
225 +
226 + func testSyncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() async throws {
227 + let bridge = FakeCalendarBridge()
228 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
229 + let eventId = bridge.seedEvent(
230 + calendarId: calendarId,
231 + snap: CalendarEventSnapshot(title: "RDV serveur", debutMs: 1_000, finMs: 2_000, serverId: "rdv-old")
232 + )
233 + var rdv = RdvEntity()
234 + rdv.serverId = "rdv-old"
235 + rdv.titre = "RDV serveur"
236 + rdv.calendarEventId = eventId
237 + rdv.updatedAt = 1
238 + _ = try await db.rdvDao.upsert(rdv)
239 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
240 + api.pullResult = .ok(
241 + SyncPullResponse(
242 + serverTime: "2026-07-22T12:00:00Z",
243 + tombstones: [TombstoneDto(entityType: "rdv", id: "rdv-old", supprimeLe: "2026-07-22T12:00:00Z")]
244 + )
245 + )
246 +
247 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
248 +
249 + let stored = try await db.rdvDao.getByServerId("rdv-old")
250 + XCTAssertNil(stored)
251 + XCTAssertTrue(bridge.deletedEventIds.contains(eventId))
252 + XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).isEmpty)
253 + }
254 +
255 + func testSyncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() async throws {
256 + let bridge = FakeCalendarBridge()
257 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
258 + let eventId = bridge.seedEvent(
259 + calendarId: calendarId,
260 + snap: CalendarEventSnapshot(title: "Réunion", debutMs: 1_000, finMs: 2_000, serverId: "resa-1")
261 + )
262 + var reservation = ReservationEntity()
263 + reservation.serverId = "resa-1"
264 + reservation.cibleType = "salle"
265 + reservation.cibleId = "s1"
266 + reservation.motif = "Réunion"
267 + reservation.calendarEventId = eventId
268 + reservation.updatedAt = 1
269 + reservation.statut = "active"
270 + _ = try await db.reservationDao.upsert(reservation)
271 + let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
272 + api.pullResult = .ok(
273 + SyncPullResponse(
274 + serverTime: "2026-07-22T12:00:00Z",
275 + reservations: [
276 + ReservationDto(
277 + id: "resa-1",
278 + cible: CibleRessourceDto(type: "salle", id: "s1"),
279 + debut: "2026-07-22T09:00:00Z",
280 + fin: "2026-07-22T10:00:00Z",
281 + statut: "annulee",
282 + creeLe: "2026-07-20T10:00:00Z",
283 + misAJourLe: "2026-07-22T11:00:00Z"
284 + ),
285 + ]
286 + )
287 + )
288 +
289 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
290 +
291 + let stored = try await db.reservationDao.getByServerId("resa-1")
292 + XCTAssertNil(stored)
293 + XCTAssertTrue(bridge.deletedEventIds.contains(eventId))
294 + }
295 +
296 + func testSyncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() async throws {
297 + let bridge = FakeCalendarBridge()
298 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
299 + let eventId = bridge.seedEvent(
300 + calendarId: calendarId,
301 + snap: CalendarEventSnapshot(title: "Nouveau RDV", debutMs: 1_000, finMs: 2_000)
302 + )
303 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
304 + api.createRdvResult = .err(code: -1, message: "Réseau indisponible")
305 +
306 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
307 +
308 + let stored = try await db.rdvDao.listAll()
309 + XCTAssertEqual(1, stored.count)
310 + XCTAssertNil(stored.first?.serverId)
311 + XCTAssertTrue(bridge.deletedEventIds.isEmpty)
312 + XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).contains { $0.eventId == eventId })
313 + }
314 +
315 + // ---- Fix MEDIUM : agendaToRoom detects calendar-side deletions ----
316 +
317 + func testAgendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() async throws {
318 + let bridge = FakeCalendarBridge()
319 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
320 + var rdv = RdvEntity()
321 + rdv.serverId = "rdv-5"
322 + rdv.titre = "RDV supprimé"
323 + rdv.calendarEventId = 99
324 + rdv.updatedAt = 1
325 + _ = try await db.rdvDao.upsert(rdv)
326 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
327 + let coordinator = AgendaSyncCoordinator(db: db)
328 +
329 + try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)
330 +
331 + let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "rdv" }
332 + XCTAssertEqual(1, ops.count)
333 + XCTAssertEqual("delete", ops[0].op)
334 + XCTAssertEqual("rdv-5", ops[0].serverId)
335 + let stored = try await db.rdvDao.getByServerId("rdv-5")
336 + XCTAssertNil(stored)
337 + }
338 +
339 + func testAgendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() async throws {
340 + let bridge = FakeCalendarBridge()
341 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
342 + var rdv = RdvEntity()
343 + rdv.titre = "RDV jamais poussé"
344 + rdv.calendarEventId = 77
345 + rdv.updatedAt = 1
346 + let localId = try await db.rdvDao.upsert(rdv)
347 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
348 + let coordinator = AgendaSyncCoordinator(db: db)
349 +
350 + try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)
351 +
352 + let stored = try await db.rdvDao.getByLocalId(localId)
353 + XCTAssertNil(stored)
354 + let ops = try await db.syncOpDao.listAll()
355 + XCTAssertTrue(ops.allSatisfy { $0.entityType != "rdv" })
356 + }
357 +
358 + func testAgendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() async throws {
359 + let bridge = FakeCalendarBridge()
360 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
361 + var reservation = ReservationEntity()
362 + reservation.serverId = "resa-9"
363 + reservation.cibleType = "salle"
364 + reservation.cibleId = "s1"
365 + reservation.motif = "Réunion supprimée"
366 + reservation.calendarEventId = 55
367 + reservation.updatedAt = 1
368 + _ = try await db.reservationDao.upsert(reservation)
369 + let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
370 + let coordinator = AgendaSyncCoordinator(db: db)
371 +
372 + try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)
373 +
374 + let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "reservation" }
375 + XCTAssertEqual(1, ops.count)
376 + XCTAssertEqual("delete", ops[0].op)
377 + XCTAssertEqual("resa-9", ops[0].serverId)
378 + let stored = try await db.reservationDao.getByServerId("resa-9")
379 + XCTAssertNil(stored)
380 + }
381 +
382 + func testSyncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() async throws {
383 + let bridge = FakeCalendarBridge()
384 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
385 + var reservation = ReservationEntity()
386 + reservation.serverId = "resa-9"
387 + reservation.cibleType = "salle"
388 + reservation.cibleId = "s1"
389 + reservation.motif = "Réunion supprimée"
390 + reservation.calendarEventId = 55
391 + reservation.updatedAt = 1
392 + _ = try await db.reservationDao.upsert(reservation)
393 + let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
394 +
395 + // agendaToRoom (via syncNow) deletes the local entity; pushOps must then
396 + // resolve the target from the op payload.
397 + _ = try await engine.syncNow(bindings: bindings, bridge: bridge)
398 +
399 + XCTAssertEqual(1, api.deleteReservationCalls.count)
400 + XCTAssertEqual("salle", api.deleteReservationCalls[0].0)
401 + XCTAssertEqual("s1", api.deleteReservationCalls[0].1)
402 + XCTAssertEqual("resa-9", api.deleteReservationCalls[0].2)
403 + }
404 +
405 + func testAgendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() async throws {
406 + let bridge = FakeCalendarBridge()
407 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
408 + var reservation = ReservationEntity()
409 + reservation.serverId = "resa-conflict"
410 + reservation.cibleType = "salle"
411 + reservation.cibleId = "s1"
412 + reservation.motif = "En conflit"
413 + reservation.calendarEventId = 12
414 + reservation.updatedAt = 1
415 + reservation.conflictPending = true
416 + _ = try await db.reservationDao.upsert(reservation)
417 + let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
418 + let coordinator = AgendaSyncCoordinator(db: db)
419 +
420 + try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)
421 +
422 + let stored = try await db.reservationDao.getByServerId("resa-conflict")
423 + XCTAssertNotNil(stored)
424 + let ops = try await db.syncOpDao.listAll()
425 + XCTAssertTrue(ops.allSatisfy { $0.entityType != "reservation" })
426 + }
427 +
428 + // ---- Fix MEDIUM : localAheadCount with orphan events ----
429 +
430 + func testLocalAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() async throws {
431 + var rdv = RdvEntity()
432 + rdv.titre = "Dirty"
433 + rdv.dirtyLocal = true
434 + _ = try await db.rdvDao.upsert(rdv)
435 + var op = SyncOpEntity(entityType: "reservation", op: "create")
436 + op.createdAt = 1
437 + _ = try await db.syncOpDao.insert(op)
438 +
439 + let count = try await engine.localAheadCount()
440 +
441 + XCTAssertEqual(2, count)
442 + }
443 +
444 + func testLocalAheadCount_withBridgeIncludesOrphanTaggedEvents() async throws {
445 + let bridge = FakeCalendarBridge()
446 + let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
447 + bridge.seedEvent(
448 + calendarId: calendarId,
449 + snap: CalendarEventSnapshot(title: "Orphelin", debutMs: 1_000, finMs: 2_000, serverId: "rdv-orphan")
450 + )
451 + let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
452 +
453 + let count = try await engine.localAheadCount(bindings: bindings, bridge: bridge)
454 +
455 + XCTAssertEqual(1, count)
456 + }
457 +
458 + // ---- Fix LOW : delete reservation without serverId ----
459 +
460 + func testPushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() async throws {
461 + var reservation = ReservationEntity()
462 + reservation.cibleType = "salle"
463 + reservation.cibleId = "s1"
464 + reservation.motif = "x"
465 + let localId = try await db.reservationDao.upsert(reservation)
466 + var op = SyncOpEntity(entityType: "reservation", op: "delete")
467 + op.localId = localId
468 + op.createdAt = 1
469 + _ = try await db.syncOpDao.insert(op)
470 +
471 + _ = try await engine.syncNow()
472 +
473 + let pending = try await db.syncOpDao.listAll()
474 + XCTAssertEqual(1, pending.count)
475 + }
476 +}
A ios/Card2vcfTests/AilianceApiClientTest.swift
+428 -0
@@ -0,0 +1,428 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// URLProtocol-based mock server (port of the MockWebServer scenarios).
5 +final class MockURLProtocol: URLProtocol {
6 + struct StubResponse {
7 + var statusCode: Int
8 + var headers: [String: String] = [:]
9 + var body: Data = Data()
10 + }
11 +
12 + struct RecordedRequest {
13 + var request: URLRequest
14 + var body: Data?
15 +
16 + var method: String? { request.httpMethod }
17 + /// Path + query, percent-encoding preserved (MockWebServer `request.path` equivalent).
18 + var path: String? {
19 + guard let url = request.url else { return nil }
20 + let absolute = url.absoluteString
21 + guard let hostRange = absolute.range(of: url.host ?? "") else { return absolute }
22 + var tail = String(absolute[hostRange.upperBound...])
23 + if let port = url.port, tail.hasPrefix(":\(port)") {
24 + tail.removeFirst(":\(port)".count)
25 + }
26 + return tail
27 + }
28 +
29 + func header(_ name: String) -> String? {
30 + request.value(forHTTPHeaderField: name)
31 + }
32 + }
33 +
34 + static var queue: [StubResponse] = []
35 + static var recorded: [RecordedRequest] = []
36 +
37 + static func reset() {
38 + queue = []
39 + recorded = []
40 + }
41 +
42 + static func enqueue(_ statusCode: Int, body: String, headers: [String: String] = [:]) {
43 + queue.append(StubResponse(statusCode: statusCode, headers: headers, body: Data(body.utf8)))
44 + }
45 +
46 + static func enqueue(_ statusCode: Int, data: Data, headers: [String: String] = [:]) {
47 + queue.append(StubResponse(statusCode: statusCode, headers: headers, body: data))
48 + }
49 +
50 + override class func canInit(with request: URLRequest) -> Bool { true }
51 +
52 + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
53 +
54 + override func startLoading() {
55 + MockURLProtocol.recorded.append(
56 + RecordedRequest(request: request, body: Self.drainBody(request))
57 + )
58 + let stub: StubResponse
59 + if MockURLProtocol.queue.isEmpty {
60 + stub = StubResponse(statusCode: 500, body: Data("no stubbed response".utf8))
61 + } else {
62 + stub = MockURLProtocol.queue.removeFirst()
63 + }
64 + let response = HTTPURLResponse(
65 + url: request.url!,
66 + statusCode: stub.statusCode,
67 + httpVersion: "HTTP/1.1",
68 + headerFields: stub.headers
69 + )!
70 + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
71 + client?.urlProtocol(self, didLoad: stub.body)
72 + client?.urlProtocolDidFinishLoading(self)
73 + }
74 +
75 + override func stopLoading() {}
76 +
77 + private static func drainBody(_ request: URLRequest) -> Data? {
78 + if let body = request.httpBody { return body }
79 + guard let stream = request.httpBodyStream else { return nil }
80 + stream.open()
81 + defer { stream.close() }
82 + var data = Data()
83 + let bufferSize = 4096
84 + var buffer = [UInt8](repeating: 0, count: bufferSize)
85 + while stream.hasBytesAvailable {
86 + let read = stream.read(&buffer, maxLength: bufferSize)
87 + if read <= 0 { break }
88 + data.append(buffer, count: read)
89 + }
90 + return data
91 + }
92 +}
93 +
94 +final class AilianceApiClientTest: XCTestCase {
95 + private static let baseUrl = "http://mock.local"
96 +
97 + private var client: AilianceApiClient!
98 +
99 + override func setUp() {
100 + super.setUp()
101 + MockURLProtocol.reset()
102 + client = AilianceApiClient(
103 + baseUrl: Self.baseUrl,
104 + apiKey: "test-bearer-key",
105 + session: Self.mockSession()
106 + )
107 + }
108 +
109 + override func tearDown() {
110 + MockURLProtocol.reset()
111 + super.tearDown()
112 + }
113 +
114 + private static func mockSession() -> URLSession {
115 + let config = URLSessionConfiguration.ephemeral
116 + config.protocolClasses = [MockURLProtocol.self]
117 + return URLSession(configuration: config)
118 + }
119 +
120 + private func takeRequest(_ index: Int = 0) throws -> MockURLProtocol.RecordedRequest {
121 + try XCTUnwrap(
122 + MockURLProtocol.recorded.indices.contains(index) ? MockURLProtocol.recorded[index] : nil
123 + )
124 + }
125 +
126 + func testAuthCle200ParsesResponse() async throws {
127 + MockURLProtocol.enqueue(
128 + 200,
129 + body: """
130 + {
131 + "nom": "alice",
132 + "cle_chiffree": "Y2lw",
133 + "sel": "c2Vs",
134 + "kdf": "argon2id",
135 + "cipher": "xchacha20poly1305"
136 + }
137 + """
138 + )
139 +
140 + let result = await client.authCle(nom: "alice", motDePasse: "secret")
141 +
142 + guard case .ok(let value) = result else {
143 + return XCTFail("expected Ok, got \(result)")
144 + }
145 + XCTAssertEqual("alice", value.nom)
146 + XCTAssertEqual("Y2lw", value.cleChiffree)
147 + XCTAssertEqual("c2Vs", value.sel)
148 + XCTAssertEqual("argon2id", value.kdf)
149 + XCTAssertEqual("xchacha20poly1305", value.cipher)
150 +
151 + let request = try takeRequest()
152 + XCTAssertEqual("POST", request.method)
153 + XCTAssertEqual("/api/auth/cle", request.path)
154 + XCTAssertNil(request.header("Authorization"))
155 + }
156 +
157 + func testAuthCle401ReturnsErr() async throws {
158 + MockURLProtocol.enqueue(401, body: #"{"message":"Identifiants invalides."}"#)
159 +
160 + let result = await client.authCle(nom: "alice", motDePasse: "wrong")
161 +
162 + guard case .err(let code, let message) = result else {
163 + return XCTFail("expected Err, got \(result)")
164 + }
165 + XCTAssertEqual(401, code)
166 + XCTAssertEqual("Identifiants invalides.", message)
167 + }
168 +
169 + func testSyncStatus200ParsesTotalAndChanges() async throws {
170 + MockURLProtocol.enqueue(
171 + 200,
172 + body: """
173 + {
174 + "server_time": "2026-07-22T12:00:00Z",
175 + "changes": {
176 + "contacts": 2,
177 + "entreprises": 0,
178 + "projets": 1,
179 + "taches": 3,
180 + "interactions": 1,
181 + "tombstones": 1
182 + },
183 + "total": 8
184 + }
185 + """
186 + )
187 +
188 + let result = await client.syncStatus(sinceIso: "1970-01-01T00:00:00Z")
189 +
190 + guard case .ok(let value) = result else {
191 + return XCTFail("expected Ok, got \(result)")
192 + }
193 + XCTAssertEqual("2026-07-22T12:00:00Z", value.serverTime)
194 + XCTAssertEqual(8, value.total)
195 + XCTAssertEqual(2, value.changes.contacts)
196 + XCTAssertEqual(1, value.changes.tombstones)
197 +
198 + let request = try takeRequest()
199 + XCTAssertEqual("GET", request.method)
200 + XCTAssertEqual("/api/sync/status?since=1970-01-01T00%3A00%3A00Z", request.path)
201 + XCTAssertEqual("Bearer test-bearer-key", request.header("Authorization"))
202 + }
203 +
204 + func testSyncPull200ParsesContactsAndTombstones() async throws {
205 + MockURLProtocol.enqueue(
206 + 200,
207 + body: """
208 + {
209 + "server_time": "2026-07-22T12:05:00Z",
210 + "contacts": [
211 + {
212 + "id": "c1",
213 + "prenom": "Jean",
214 + "nom": "Dupont",
215 + "cree_le": "2026-07-20T10:00:00Z",
216 + "mis_a_jour_le": "2026-07-21T08:00:00Z"
217 + }
218 + ],
219 + "entreprises": [],
220 + "projets": [],
221 + "taches": [
222 + {
223 + "projet_id": "p1",
224 + "id": "t1",
225 + "titre": "Appeler client",
226 + "colonne_id": "col-a-faire",
227 + "ordre": 0,
228 + "cree_le": "2026-07-21T09:00:00Z",
229 + "assigne_a": "alice"
230 + }
231 + ],
232 + "interactions": [],
233 + "tombstones": [
234 + {
235 + "type": "contact",
236 + "id": "c-old",
237 + "supprime_le": "2026-07-22T11:00:00Z"
238 + }
239 + ],
240 + "workflows": []
241 + }
242 + """
243 + )
244 +
245 + let result = await client.syncPull(sinceIso: "2026-07-20T00:00:00Z")
246 +
247 + guard case .ok(let value) = result else {
248 + return XCTFail("expected Ok, got \(result)")
249 + }
250 + XCTAssertEqual(1, value.contacts.count)
251 + XCTAssertEqual("c1", value.contacts[0].id)
252 + XCTAssertEqual("Jean", value.contacts[0].prenom)
253 + XCTAssertEqual(1, value.taches.count)
254 + XCTAssertEqual("p1", value.taches[0].projetId)
255 + XCTAssertEqual("alice", value.taches[0].assigneA)
256 + XCTAssertEqual(1, value.tombstones.count)
257 + XCTAssertEqual("contact", value.tombstones[0].entityType)
258 + XCTAssertEqual("c-old", value.tombstones[0].id)
259 + }
260 +
261 + func testSyncPull200ParsesAgendaFields() async throws {
262 + MockURLProtocol.enqueue(
263 + 200,
264 + body: """
265 + {
266 + "server_time": "2026-07-22T12:05:00Z",
267 + "contacts": [],
268 + "entreprises": [],
269 + "projets": [],
270 + "taches": [],
271 + "interactions": [],
272 + "rdv": [
273 + {
274 + "id": "rdv1",
275 + "titre": "Point client",
276 + "utilisateur": "alice",
277 + "debut": "2026-07-22T09:00:00Z",
278 + "fin": "2026-07-22T10:00:00Z",
279 + "cree_par": "alice",
280 + "cree_le": "2026-07-20T08:00:00Z"
281 + }
282 + ],
283 + "reservations": [
284 + {
285 + "id": "resa1",
286 + "cible": {"type": "salle", "id": "salle-a"},
287 + "debut": "2026-07-22T09:00:00Z",
288 + "fin": "2026-07-22T10:00:00Z",
289 + "motif": "Réunion",
290 + "statut": "active",
291 + "reserve_par": "alice",
292 + "cree_le": "2026-07-20T08:00:00Z"
293 + }
294 + ],
295 + "indisponibilites": [
296 + {
297 + "id": "indispo1",
298 + "cible": {"type": "vehicule", "id": "vehicule-a"},
299 + "nature": "entretien",
300 + "debut": "2026-07-22T09:00:00Z",
301 + "fin": "2026-07-22T10:00:00Z",
302 + "cree_par": "alice",
303 + "cree_le": "2026-07-20T08:00:00Z"
304 + }
305 + ],
306 + "tombstones": [],
307 + "workflows": []
308 + }
309 + """
310 + )
311 +
312 + let result = await client.syncPull(sinceIso: "2026-07-20T00:00:00Z")
313 +
314 + guard case .ok(let value) = result else {
315 + return XCTFail("expected Ok, got \(result)")
316 + }
317 + XCTAssertEqual(1, value.rdv.count)
318 + XCTAssertEqual("Point client", value.rdv[0].titre)
319 + XCTAssertEqual(1, value.reservations.count)
320 + XCTAssertEqual("salle", value.reservations[0].cible.type)
321 + XCTAssertEqual("salle-a", value.reservations[0].cible.id)
322 + XCTAssertEqual(1, value.indisponibilites.count)
323 + XCTAssertEqual("entretien", value.indisponibilites[0].nature)
324 + }
325 +
326 + func testCreateReservation409ReturnsErrWithCode() async throws {
327 + MockURLProtocol.enqueue(
328 + 409,
329 + body: #"{"message":"Réservation refusée : période en conflit.","reclamable":true}"#
330 + )
331 +
332 + let result = await client.createReservation(
333 + genre: "salle",
334 + resourceId: "salle-a",
335 + jsonBody: #"{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z","motif":"Réunion"}"#
336 + )
337 +
338 + guard case .err(let code, let message) = result else {
339 + return XCTFail("expected Err, got \(result)")
340 + }
341 + XCTAssertEqual(409, code)
342 + XCTAssertEqual("Réservation refusée : période en conflit.", message)
343 +
344 + let request = try takeRequest()
345 + XCTAssertEqual("POST", request.method)
346 + XCTAssertEqual("/api/ressources/salle/salle-a/reservations", request.path)
347 + }
348 +
349 + func testGetRessourcesCatalogue200ParsesCatalogue() async throws {
350 + MockURLProtocol.enqueue(
351 + 200,
352 + body: """
353 + {
354 + "salles": [{"id": "salle-a", "nom": "Salle A", "actif": true}],
355 + "materiels": [],
356 + "vehicules": [{"id": "vehicule-a", "nom": "Véhicule A", "actif": false}]
357 + }
358 + """
359 + )
360 +
361 + let result = await client.getRessourcesCatalogue()
362 +
363 + guard case .ok(let value) = result else {
364 + return XCTFail("expected Ok, got \(result)")
365 + }
366 + XCTAssertEqual(1, value.salles.count)
367 + XCTAssertEqual("Salle A", value.salles[0].nom)
368 + XCTAssertTrue(value.salles[0].actif)
369 + XCTAssertEqual(0, value.materiels.count)
370 + XCTAssertEqual(1, value.vehicules.count)
371 + XCTAssertFalse(value.vehicules[0].actif)
372 +
373 + let request = try takeRequest()
374 + XCTAssertEqual("GET", request.method)
375 + XCTAssertEqual("/api/ressources", request.path)
376 + }
377 +
378 + func testProtectedEndpointWithoutBearerReturns401() async throws {
379 + let unauthenticated = AilianceApiClient(
380 + baseUrl: Self.baseUrl,
381 + apiKey: nil,
382 + session: Self.mockSession()
383 + )
384 + MockURLProtocol.enqueue(401, body: #"{"message":"Non autorisé."}"#)
385 +
386 + let result = await unauthenticated.syncStatus(sinceIso: "1970-01-01T00:00:00Z")
387 +
388 + guard case .err(let code, _) = result else {
389 + return XCTFail("expected Err, got \(result)")
390 + }
391 + XCTAssertEqual(401, code)
392 +
393 + let request = try takeRequest()
394 + XCTAssertNil(request.header("Authorization"))
395 + }
396 +
397 + func testUploadContactCarte_sendsMultipartWithBearer() async throws {
398 + MockURLProtocol.enqueue(
399 + 200,
400 + body: #"{"id":"c1","carte_visite":"carte.jpg","cree_le":"2026-07-22T00:00:00Z"}"#
401 + )
402 +
403 + let result = await client.uploadContactCarte(id: "c1", bytes: Data([1, 2, 3]), filename: "card.jpg")
404 +
405 + XCTAssertTrue(result.isOk)
406 + let request = try takeRequest()
407 + XCTAssertEqual("POST", request.method)
408 + XCTAssertEqual("/api/contacts/c1/carte", request.path)
409 + XCTAssertEqual("Bearer test-bearer-key", request.header("Authorization"))
410 + let body = String(data: try XCTUnwrap(request.body), encoding: .utf8) ?? ""
411 + XCTAssertTrue(body.contains("filename=\"card.jpg\""))
412 + XCTAssertTrue(try XCTUnwrap(request.header("Content-Type")).hasPrefix("multipart/form-data"))
413 + }
414 +
415 + func testDownloadContactCarte_returnsBytes() async throws {
416 + MockURLProtocol.enqueue(200, data: Data([10, 20, 30]), headers: ["Content-Type": "image/jpeg"])
417 +
418 + let result = await client.downloadContactCarte(id: "c1")
419 +
420 + guard case .ok(let bytes) = result else {
421 + return XCTFail("expected Ok, got \(result)")
422 + }
423 + XCTAssertEqual(Data([10, 20, 30]), bytes)
424 + let request = try takeRequest()
425 + XCTAssertEqual("GET", request.method)
426 + XCTAssertEqual("/api/contacts/c1/carte", request.path)
427 + }
428 +}
A ios/Card2vcfTests/AlphabetIndexTest.swift
+67 -0
@@ -0,0 +1,67 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class AlphabetIndexTest: XCTestCase {
5 + private func contact(
6 + _ id: Int64,
7 + lastName: String? = nil,
8 + firstName: String? = nil,
9 + fullName: String? = nil,
10 + company: String? = nil,
11 + createdAt: Int64 = 0
12 + ) -> CrmContactEntity {
13 + CrmContactEntity(
14 + id: id,
15 + fullName: fullName,
16 + firstName: firstName,
17 + lastName: lastName,
18 + company: company,
19 + createdAt: createdAt
20 + )
21 + }
22 +
23 + func testSectionLetterAzAndHash() {
24 + XCTAssertEqual("A", AlphabetIndex.sectionLetter("alice"))
25 + XCTAssertEqual("Z", AlphabetIndex.sectionLetter(" zebra"))
26 + XCTAssertEqual("#", AlphabetIndex.sectionLetter(""))
27 + XCTAssertEqual("#", AlphabetIndex.sectionLetter(" "))
28 + XCTAssertEqual("#", AlphabetIndex.sectionLetter("Émile"))
29 + XCTAssertEqual("#", AlphabetIndex.sectionLetter("9ops"))
30 + }
31 +
32 + func testSortKeyFallbacks() {
33 + let withLast = contact(1, lastName: "Dupont", fullName: "Jean Dupont")
34 + let onlyFull = contact(2, fullName: "Solo")
35 + XCTAssertEqual("Dupont", AlphabetIndex.sortKey(withLast, sort: .lastName))
36 + XCTAssertEqual("Solo", AlphabetIndex.sortKey(onlyFull, sort: .lastName))
37 + XCTAssertEqual("Solo", AlphabetIndex.sortKey(onlyFull, sort: .firstName))
38 + XCTAssertEqual("", AlphabetIndex.sortKey(contact(3), sort: .company))
39 + XCTAssertEqual("00000000000000000042", AlphabetIndex.sortKey(contact(4, createdAt: 42), sort: .createdAt))
40 + }
41 +
42 + func testGroupByLastNameSections() {
43 + let contacts = [
44 + contact(1, lastName: "Bernard"),
45 + contact(2, lastName: "Adams"),
46 + contact(3, lastName: "Dupont"),
47 + contact(4, lastName: "123"),
48 + ]
49 + let grouped = AlphabetIndex.group(contacts: contacts, sort: .lastName)
50 + XCTAssertEqual(["A", "B", "D", "#"], grouped.map { $0.letter })
51 + XCTAssertEqual([2], grouped.first { $0.letter == "A" }?.contacts.map { $0.id })
52 + XCTAssertEqual([1], grouped.first { $0.letter == "B" }?.contacts.map { $0.id })
53 + XCTAssertEqual([3], grouped.first { $0.letter == "D" }?.contacts.map { $0.id })
54 + XCTAssertEqual([4], grouped.first { $0.letter == "#" }?.contacts.map { $0.id })
55 + }
56 +
57 + func testCreatedAtFlatChronoDescNoAzSections() {
58 + let contacts = [
59 + contact(1, lastName: "A", createdAt: 10),
60 + contact(2, lastName: "B", createdAt: 30),
61 + contact(3, lastName: "C", createdAt: 20),
62 + ]
63 + let grouped = AlphabetIndex.group(contacts: contacts, sort: .createdAt)
64 + XCTAssertEqual(1, grouped.count)
65 + XCTAssertEqual([2, 3, 1], grouped[0].contacts.map { $0.id })
66 + }
67 +}
A ios/Card2vcfTests/ApiCleCryptoTest.swift
+45 -0
@@ -0,0 +1,45 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ApiCleCryptoTest: XCTestCase {
5 + private static let FIXTURE_MDP = "card2vcf-fixture-mdp"
6 + private static let FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
7 + private static let FIXTURE_SEL_B64 = "Fc+EFikMN7Cb8YoNZB+SZw=="
8 + private static let FIXTURE_BLOB_B64 =
9 + "uW9uB9fP7m5ng6PoH9PGCXBKdPoVj9SceWy9GWLjg4x3Zk7MirbHhSCBVyLzoL2tK4qDir7UCOn503+s9rUFvIT/Eg5NqaU="
10 + // 23 bytes (< 24 nonce minimum)
11 + private static let SHORT_BLOB_B64 = "AAAAAAAAAAAAAAAAAAAAAAA="
12 +
13 + func testDecryptRustFixture() throws {
14 + let key = try ApiCleCrypto.decryptApiKey(
15 + password: Self.FIXTURE_MDP,
16 + saltB64: Self.FIXTURE_SEL_B64,
17 + cipherB64: Self.FIXTURE_BLOB_B64
18 + )
19 + XCTAssertEqual(Self.FIXTURE_CLE, key)
20 + }
21 +
22 + func testWrongPasswordFails() {
23 + XCTAssertThrowsError(
24 + try ApiCleCrypto.decryptApiKey(
25 + password: "wrong-password",
26 + saltB64: Self.FIXTURE_SEL_B64,
27 + cipherB64: Self.FIXTURE_BLOB_B64
28 + )
29 + ) { error in
30 + XCTAssertTrue(error is ApiCleCryptoException)
31 + }
32 + }
33 +
34 + func testBlobTooShortFails() {
35 + XCTAssertThrowsError(
36 + try ApiCleCrypto.decryptApiKey(
37 + password: Self.FIXTURE_MDP,
38 + saltB64: Self.FIXTURE_SEL_B64,
39 + cipherB64: Self.SHORT_BLOB_B64
40 + )
41 + ) { error in
42 + XCTAssertTrue(error is ApiCleCryptoException)
43 + }
44 + }
45 +}
A ios/Card2vcfTests/CalendarBindingsStoreTest.swift
+103 -0
@@ -0,0 +1,103 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class CalendarBindingsStoreTest: XCTestCase {
5 + private static let suiteName = "card2vcf_calendar_bindings_test"
6 +
7 + private var defaults: UserDefaults!
8 + private var store: CalendarBindingsStore!
9 +
10 + override func setUp() {
11 + super.setUp()
12 + defaults = UserDefaults(suiteName: Self.suiteName)
13 + defaults.removePersistentDomain(forName: Self.suiteName)
14 + store = CalendarBindingsStore(defaults: defaults)
15 + }
16 +
17 + override func tearDown() {
18 + defaults.removePersistentDomain(forName: Self.suiteName)
19 + super.tearDown()
20 + }
21 +
22 + func testInitiallyEmpty() {
23 + XCTAssertTrue(store.list().isEmpty)
24 + }
25 +
26 + func testUpsertRoundTrip() {
27 + let binding = CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: 42)
28 +
29 + store.upsert(binding)
30 +
31 + XCTAssertEqual([binding], store.list())
32 + }
33 +
34 + func testUpsertReplacesExistingBindingWithSameKindAndServerResourceId() {
35 + let original = CalendarBinding(
36 + kind: "salle",
37 + serverResourceId: "salle-1",
38 + displayName: "Salle A",
39 + androidCalendarId: 1
40 + )
41 + var renamed = original
42 + renamed.displayName = "Salle A (renommée)"
43 + renamed.androidCalendarId = 2
44 +
45 + store.upsert(original)
46 + store.upsert(renamed)
47 +
48 + XCTAssertEqual([renamed], store.list())
49 + }
50 +
51 + func testUpsertKeepsDistinctBindingsByKindAndServerResourceId() {
52 + let rdv = CalendarBinding(kind: "rdv", displayName: "Mes RDV")
53 + let salle = CalendarBinding(kind: "salle", serverResourceId: "salle-1", displayName: "Salle A")
54 + let materiel = CalendarBinding(kind: "materiel", serverResourceId: "materiel-1", displayName: "Matériel A")
55 +
56 + store.upsert(rdv)
57 + store.upsert(salle)
58 + store.upsert(materiel)
59 +
60 + XCTAssertEqual(3, store.list().count)
61 + XCTAssertTrue(store.list().contains(rdv))
62 + XCTAssertTrue(store.list().contains(salle))
63 + XCTAssertTrue(store.list().contains(materiel))
64 + }
65 +
66 + func testRemoveDeletesMatchingBinding() {
67 + let salle = CalendarBinding(kind: "salle", serverResourceId: "salle-1", displayName: "Salle A")
68 + store.upsert(salle)
69 +
70 + store.remove(kind: "salle", serverResourceId: "salle-1")
71 +
72 + XCTAssertTrue(store.list().isEmpty)
73 + }
74 +
75 + func testRemoveOnlyAffectsMatchingKindAndServerResourceId() {
76 + let salleA = CalendarBinding(kind: "salle", serverResourceId: "salle-1", displayName: "Salle A")
77 + let salleB = CalendarBinding(kind: "salle", serverResourceId: "salle-2", displayName: "Salle B")
78 + store.upsert(salleA)
79 + store.upsert(salleB)
80 +
81 + store.remove(kind: "salle", serverResourceId: "salle-1")
82 +
83 + XCTAssertEqual([salleB], store.list())
84 + }
85 +
86 + func testRemoveWithNilServerResourceIdTargetsRdvBinding() {
87 + let rdv = CalendarBinding(kind: "rdv", displayName: "Mes RDV")
88 + store.upsert(rdv)
89 +
90 + store.remove(kind: "rdv", serverResourceId: nil)
91 +
92 + XCTAssertTrue(store.list().isEmpty)
93 + }
94 +
95 + func testClearRemovesAllBindings() {
96 + store.upsert(CalendarBinding(kind: "rdv", displayName: "Mes RDV"))
97 + store.upsert(CalendarBinding(kind: "salle", serverResourceId: "salle-1", displayName: "Salle A"))
98 +
99 + store.clear()
100 +
101 + XCTAssertTrue(store.list().isEmpty)
102 + }
103 +}
A ios/Card2vcfTests/CalendarServerIdCodecTest.swift
+87 -0
@@ -0,0 +1,87 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class CalendarServerIdCodecTest: XCTestCase {
5 + func testEncodeWithServerIdPrependsPrefixLine() {
6 + let encoded = CalendarServerIdCodec.encode(serverId: "abc-123", descriptionBody: "RDV client")
7 +
8 + XCTAssertEqual("card2vcf:serverId=abc-123\nRDV client", encoded)
9 + }
10 +
11 + func testEncodeWithNilServerIdReturnsBodyUnchanged() {
12 + let encoded = CalendarServerIdCodec.encode(serverId: nil, descriptionBody: "RDV client")
13 +
14 + XCTAssertEqual("RDV client", encoded)
15 + }
16 +
17 + func testEncodeWithEmptyBodyKeepsTrailingNewline() {
18 + let encoded = CalendarServerIdCodec.encode(serverId: "abc-123", descriptionBody: "")
19 +
20 + XCTAssertEqual("card2vcf:serverId=abc-123\n", encoded)
21 + }
22 +
23 + func testDecodeExtractsServerIdAndBody() {
24 + let (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nRDV client")
25 +
26 + XCTAssertEqual("abc-123", serverId)
27 + XCTAssertEqual("RDV client", body)
28 + }
29 +
30 + func testDecodeWithoutPrefixReturnsNilServerIdAndFullDescription() {
31 + let (serverId, body) = CalendarServerIdCodec.decode("Simple description sans serverId")
32 +
33 + XCTAssertNil(serverId)
34 + XCTAssertEqual("Simple description sans serverId", body)
35 + }
36 +
37 + func testDecodeNilDescriptionReturnsNilAndEmptyBody() {
38 + let (serverId, body) = CalendarServerIdCodec.decode(nil)
39 +
40 + XCTAssertNil(serverId)
41 + XCTAssertEqual("", body)
42 + }
43 +
44 + func testDecodeWithPrefixButNoNewlineTreatsRestAsServerId() {
45 + let (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123")
46 +
47 + XCTAssertEqual("abc-123", serverId)
48 + XCTAssertEqual("", body)
49 + }
50 +
51 + func testDecodeWithMultilineBodyPreservesInternalNewlines() {
52 + let (serverId, body) = CalendarServerIdCodec.decode("card2vcf:serverId=abc-123\nligne1\nligne2")
53 +
54 + XCTAssertEqual("abc-123", serverId)
55 + XCTAssertEqual("ligne1\nligne2", body)
56 + }
57 +
58 + func testRoundTripEncodeThenDecodePreservesServerIdAndBody() {
59 + let encoded = CalendarServerIdCodec.encode(serverId: "srv-42", descriptionBody: "Détails\nsur plusieurs lignes")
60 + let (serverId, body) = CalendarServerIdCodec.decode(encoded)
61 +
62 + XCTAssertEqual("srv-42", serverId)
63 + XCTAssertEqual("Détails\nsur plusieurs lignes", body)
64 + }
65 +
66 + func testRoundTripWithoutServerIdPreservesBody() {
67 + let encoded = CalendarServerIdCodec.encode(serverId: nil, descriptionBody: "Sans serverId")
68 + let (serverId, body) = CalendarServerIdCodec.decode(encoded)
69 +
70 + XCTAssertNil(serverId)
71 + XCTAssertEqual("Sans serverId", body)
72 + }
73 +
74 + func testIndispoTitleAddsPrefix() {
75 + XCTAssertEqual("[Indispo] Salle A fermée", CalendarEventTitles.indispoTitle("Salle A fermée"))
76 + }
77 +
78 + func testIsIndispoTitleDetectsPrefix() {
79 + XCTAssertTrue(CalendarEventTitles.isIndispoTitle("[Indispo] Salle A fermée"))
80 + XCTAssertFalse(CalendarEventTitles.isIndispoTitle("RDV client"))
81 + }
82 +
83 + func testStripIndispoPrefixRemovesPrefixWhenPresent() {
84 + XCTAssertEqual("Salle A fermée", CalendarEventTitles.stripIndispoPrefix("[Indispo] Salle A fermée"))
85 + XCTAssertEqual("RDV client", CalendarEventTitles.stripIndispoPrefix("RDV client"))
86 + }
87 +}
A ios/Card2vcfTests/ContactCardTest.swift
+33 -0
@@ -0,0 +1,33 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactCardTest: XCTestCase {
5 + func testChampsPartiels() {
6 + let c = ContactCard(phones: ["+33612345678"])
7 + XCTAssertEqual("", c.displayName())
8 + XCTAssertTrue(c.hasAnyField())
9 + }
10 +
11 + func testDisplayNamePrioriseFullName() {
12 + let c = ContactCard(fullName: "Jean Dupont", firstName: "Jean", lastName: "Dupont")
13 + XCTAssertEqual("Jean Dupont", c.displayName())
14 + }
15 +
16 + func testDisplayNameDepuisPrenomNom() {
17 + let c = ContactCard(firstName: "Jean", lastName: "Dupont")
18 + XCTAssertEqual("Jean Dupont", c.displayName())
19 + }
20 +
21 + func testListesMultiples() {
22 + let c = ContactCard(
23 + phones: ["0612345678", "0145678901"],
24 + emails: ["a@b.fr", "c@d.fr"]
25 + )
26 + XCTAssertEqual(2, c.phones.count)
27 + XCTAssertEqual(2, c.emails.count)
28 + }
29 +
30 + func testVideSansChamp() {
31 + XCTAssertFalse(ContactCard().hasAnyField())
32 + }
33 +}
A ios/Card2vcfTests/ContactDraftMergeTest.swift
+31 -0
@@ -0,0 +1,31 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactDraftMergeTest: XCTestCase {
5 + func testFusionneRegexEtLlm() {
6 + let llm = ContactCard(fullName: "Jean Dupont", phones: ["0612345678"])
7 + let ocr = OcrResult(
8 + rawText: "Jean Dupont\n0612345678\njean@acme.fr",
9 + phones: ["0612345678", "0145678901"],
10 + emails: ["jean@acme.fr"],
11 + urls: ["https://acme.fr"]
12 + )
13 + let merged = ContactDraftMerge.merge(llm: llm, ocr: ocr)
14 + XCTAssertEqual("Jean Dupont", merged.fullName)
15 + XCTAssertEqual(["0612345678", "0145678901"], merged.phones)
16 + XCTAssertEqual(["jean@acme.fr"], merged.emails)
17 + XCTAssertEqual("https://acme.fr", merged.website)
18 + }
19 +
20 + func testFallbackRegexSiLlmNull() {
21 + let ocr = OcrResult(
22 + rawText: "x",
23 + phones: ["0611111111"],
24 + emails: ["a@b.fr"],
25 + urls: ["https://x.fr"]
26 + )
27 + let merged = ContactDraftMerge.merge(llm: nil, ocr: ocr)
28 + XCTAssertEqual(["0611111111"], merged.phones)
29 + XCTAssertEqual("https://x.fr", merged.website)
30 + }
31 +}
A ios/Card2vcfTests/ContactHeuristicParserTest.swift
+66 -0
@@ -0,0 +1,66 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactHeuristicParserTest: XCTestCase {
5 +
6 + func testParseCarteDimoBruitee() {
7 + let text = """
8 + dimo
9 + SOFTWARE &—
10 + Jean Pau! GENOUX
11 + Directeur Général/CEO
12 + jpgenoux@dimosoftware.com
13 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
14 + Siège Social
15 + 561, alée des Noisetiers - 69 760 Limonest - France
16 + Lyon - Paris - Nantes - Biarritz - Madrid — Toronto
17 + www.dimosoftware.fr
18 + """
19 + let ocr = OcrPostProcessor.process(text)
20 + let card = ContactHeuristicParser.parse(ocr)
21 +
22 + XCTAssertEqual("Jean Paul Genoux", card.fullName)
23 + XCTAssertEqual("Jean Paul", card.firstName)
24 + XCTAssertEqual("Genoux", card.lastName)
25 + XCTAssertTrue(card.jobTitle!.range(of: "Directeur", options: .caseInsensitive) != nil)
26 + XCTAssertTrue(
27 + card.company!.range(of: "Software", options: .caseInsensitive) != nil
28 + || card.company!.range(of: "dimo", options: .caseInsensitive) != nil
29 + )
30 + XCTAssertTrue(card.emails.contains("jpgenoux@dimosoftware.com"))
31 + XCTAssertTrue(card.phones.contains { $0.hasSuffix("674640544") })
32 + XCTAssertTrue(card.phones.contains { $0.contains("472860192") })
33 + XCTAssertTrue(card.website!.contains("dimosoftware.fr"))
34 + XCTAssertNotNil(card.address)
35 + XCTAssertTrue(card.address!.contains("Noisetiers") || card.address!.contains("Limonest"))
36 + }
37 +
38 + func testSplitNomAvecNomFamilleMajuscules() {
39 + let (first, last, full) = ContactHeuristicParser.splitPersonName("Marie CURIE")
40 + XCTAssertEqual("Marie", first)
41 + XCTAssertEqual("Curie", last)
42 + XCTAssertEqual("Marie Curie", full)
43 + }
44 +
45 + func testSocieteDepuisEmail() {
46 + XCTAssertEqual("Dimo Software", ContactHeuristicParser.companyFromEmailDomain("jp@dimosoftware.com"))
47 + }
48 +
49 + func testMergeSansLlmRemplitQuandMemeLesChamps() {
50 + let ocr = OcrPostProcessor.process(
51 + """
52 + Acme SAS
53 + Alice Martin
54 + CEO
55 + alice@acme.fr
56 + 01 23 45 67 89
57 + 10 rue de Paris 75001 Paris
58 + """
59 + )
60 + let merged = ContactDraftMerge.merge(llm: nil, ocr: ocr)
61 + XCTAssertEqual("Alice Martin", merged.fullName)
62 + XCTAssertTrue(merged.emails.contains("alice@acme.fr"))
63 + XCTAssertTrue(merged.jobTitle!.range(of: "CEO", options: .caseInsensitive) != nil)
64 + XCTAssertNotNil(merged.company)
65 + }
66 +}
A ios/Card2vcfTests/ContactMergeTest.swift
+134 -0
@@ -0,0 +1,134 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactMergeTest: XCTestCase {
5 + private func contact(
6 + _ id: Int64,
7 + fullName: String? = nil,
8 + firstName: String? = nil,
9 + lastName: String? = nil,
10 + company: String? = nil,
11 + jobTitle: String? = nil,
12 + website: String? = nil,
13 + address: String? = nil,
14 + notes: String? = nil,
15 + phones: [String] = [],
16 + emails: [String] = []
17 + ) -> CrmContactEntity {
18 + CrmContactEntity(
19 + id: id,
20 + fullName: fullName,
21 + firstName: firstName,
22 + lastName: lastName,
23 + company: company,
24 + jobTitle: jobTitle,
25 + phones: phones,
26 + emails: emails,
27 + website: website,
28 + address: address,
29 + notes: notes,
30 + createdAt: id,
31 + updatedAt: id,
32 + phonesText: phones.joined(separator: " "),
33 + emailsText: emails.joined(separator: " ")
34 + )
35 + }
36 +
37 + private func choices(
38 + keepId: Int64,
39 + notesMode: NotesMode = .keepPrimary,
40 + fullNameFromId: Int64? = nil,
41 + firstNameFromId: Int64? = nil,
42 + lastNameFromId: Int64? = nil,
43 + companyFromId: Int64? = nil,
44 + jobTitleFromId: Int64? = nil,
45 + websiteFromId: Int64? = nil,
46 + addressFromId: Int64? = nil
47 + ) -> MergeFieldChoices {
48 + MergeFieldChoices(
49 + fullNameFromId: fullNameFromId ?? keepId,
50 + firstNameFromId: firstNameFromId ?? keepId,
51 + lastNameFromId: lastNameFromId ?? keepId,
52 + companyFromId: companyFromId ?? keepId,
53 + jobTitleFromId: jobTitleFromId ?? keepId,
54 + websiteFromId: websiteFromId ?? keepId,
55 + addressFromId: addressFromId ?? keepId,
56 + notesMode: notesMode,
57 + cardImageFromId: nil,
58 + profileImageFromId: nil,
59 + keepId: keepId
60 + )
61 + }
62 +
63 + func testPicksScalarsAndUnionsLists() {
64 + let a = contact(
65 + 1,
66 + fullName: "A",
67 + firstName: "Ada",
68 + company: "CoA",
69 + notes: "note A",
70 + phones: ["111"],
71 + emails: ["a@ex.com"]
72 + )
73 + let b = contact(
74 + 2,
75 + fullName: "B",
76 + lastName: "Lovelace",
77 + jobTitle: "Eng",
78 + website: "https://b",
79 + address: "Paris",
80 + notes: "note B",
81 + phones: ["111", "222"],
82 + emails: ["b@ex.com"]
83 + )
84 + let merged = ContactMerge.merge(
85 + sources: [a, b],
86 + choices: choices(
87 + keepId: 1,
88 + fullNameFromId: 2,
89 + lastNameFromId: 2,
90 + jobTitleFromId: 2,
91 + websiteFromId: 2,
92 + addressFromId: 2
93 + ),
94 + now: 99
95 + )
96 + XCTAssertEqual(1, merged.id)
97 + XCTAssertEqual("B", merged.fullName)
98 + XCTAssertEqual("Ada", merged.firstName)
99 + XCTAssertEqual("Lovelace", merged.lastName)
100 + XCTAssertEqual("CoA", merged.company)
101 + XCTAssertEqual("Eng", merged.jobTitle)
102 + XCTAssertEqual("https://b", merged.website)
103 + XCTAssertEqual("Paris", merged.address)
104 + XCTAssertEqual("note A", merged.notes)
105 + XCTAssertEqual(["111", "222"], merged.phones)
106 + XCTAssertEqual(["a@ex.com", "b@ex.com"], merged.emails)
107 + XCTAssertEqual("111 222", merged.phonesText)
108 + XCTAssertEqual("a@ex.com b@ex.com", merged.emailsText)
109 + XCTAssertEqual(99, merged.updatedAt)
110 + XCTAssertEqual(1, merged.createdAt)
111 + }
112 +
113 + func testConcatNotes() {
114 + let a = contact(1, notes: "alpha")
115 + let b = contact(2, notes: "beta")
116 + let merged = ContactMerge.merge(
117 + sources: [a, b],
118 + choices: choices(keepId: 1, notesMode: .concat),
119 + now: 1
120 + )
121 + XCTAssertEqual("alpha\n---\nbeta", merged.notes)
122 + }
123 +
124 + func testConcatNotesBlankBecomesNull() {
125 + let a = contact(1, notes: " ")
126 + let b = contact(2, notes: nil)
127 + let merged = ContactMerge.merge(
128 + sources: [a, b],
129 + choices: choices(keepId: 1, notesMode: .concat),
130 + now: 1
131 + )
132 + XCTAssertNil(merged.notes)
133 + }
134 +}
A ios/Card2vcfTests/ContactNormalizerTest.swift
+12 -0
@@ -0,0 +1,12 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactNormalizerTest: XCTestCase {
5 + func testEmailTrimLowercase() {
6 + XCTAssertEqual("a@b.c", ContactNormalizer.email(" A@B.C "))
7 + }
8 +
9 + func testPhoneDigitsOnly() {
10 + XCTAssertEqual("3361234", ContactNormalizer.phone("+33 6 12-34"))
11 + }
12 +}
A ios/Card2vcfTests/ContactSyncMapperTest.swift
+56 -0
@@ -0,0 +1,56 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class ContactSyncMapperTest: XCTestCase {
5 + private let mapper = ContactSyncMapper()
6 +
7 + func testMapsLocalFieldsToApiJson() throws {
8 + let entity = CrmContactEntity(
9 + id: 1,
10 + fullName: "Marie Dubois",
11 + firstName: "Marie",
12 + lastName: "Dubois",
13 + jobTitle: "Directrice",
14 + phones: ["+33 1 23 45 67 89"],
15 + emails: ["marie@acme.fr"],
16 + notes: "Rencontrée au salon",
17 + serverId: nil,
18 + statut: "client",
19 + etape: "qualifie",
20 + tags: ["vip", "lyon"],
21 + entrepriseServerId: "ent-42"
22 + )
23 +
24 + let payload = mapper.toCreatePayload(entity)
25 + let request = try SyncJson.decode(CreateContactRequest.self, from: payload)
26 +
27 + XCTAssertEqual("Marie", request.prenom)
28 + XCTAssertEqual("Dubois", request.nom)
29 + XCTAssertEqual("ent-42", request.entrepriseId)
30 + XCTAssertEqual("Directrice", request.fonction)
31 + XCTAssertEqual("marie@acme.fr", request.emails.first?.valeur)
32 + XCTAssertEqual(1, request.emails.count)
33 + XCTAssertEqual("+33 1 23 45 67 89", request.telephones.first?.valeur)
34 + XCTAssertEqual(1, request.telephones.count)
35 + XCTAssertEqual("Rencontrée au salon", request.notes)
36 + XCTAssertEqual("client", request.statut)
37 + XCTAssertEqual("qualifie", request.etape)
38 + XCTAssertEqual(["vip", "lyon"], request.tags)
39 + }
40 +
41 + func testSplitsFullNameWhenFirstAndLastMissing() throws {
42 + let entity = CrmContactEntity(
43 + fullName: "Jean Paul Sartre",
44 + emails: ["jp@ex.com"]
45 + )
46 +
47 + let request = try SyncJson.decode(
48 + CreateContactRequest.self,
49 + from: mapper.toCreatePayload(entity)
50 + )
51 +
52 + XCTAssertEqual("Jean", request.prenom)
53 + XCTAssertEqual("Paul Sartre", request.nom)
54 + XCTAssertNil(request.entrepriseId)
55 + }
56 +}
A ios/Card2vcfTests/CrmContactDaoTest.swift
+209 -0
@@ -0,0 +1,209 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class CrmContactDaoTest: XCTestCase {
5 + private var db: Card2vcfDatabase!
6 + private var dao: (any CrmContactDao)!
7 +
8 + override func setUp() {
9 + super.setUp()
10 + db = Card2vcfDatabase(inMemory: true)
11 + dao = db.crmContactDao
12 + }
13 +
14 + override func tearDown() {
15 + dao = nil
16 + db = nil
17 + super.tearDown()
18 + }
19 +
20 + private func makeContact(
21 + fullName: String? = nil,
22 + phones: [String] = [],
23 + emails: [String] = [],
24 + notes: String? = nil,
25 + createdAt: Int64 = 0,
26 + serverId: String? = nil
27 + ) -> CrmContactEntity {
28 + var e = CrmContactEntity()
29 + e.fullName = fullName
30 + e.phones = phones
31 + e.emails = emails
32 + e.notes = notes
33 + e.createdAt = createdAt
34 + e.updatedAt = createdAt
35 + e.phonesText = phones.joined(separator: " ")
36 + e.emailsText = emails.joined(separator: " ")
37 + e.serverId = serverId
38 + return e
39 + }
40 +
41 + // MARK: - Round trip
42 +
43 + func testInsertGetByIdRoundTrip() async throws {
44 + var contact = makeContact(
45 + fullName: "Jean Dupont",
46 + phones: ["+33612345678", "0145678901"],
47 + emails: ["jean@exemple.fr"],
48 + notes: "rencontré au salon",
49 + createdAt: 1000
50 + )
51 + contact.company = "ACME"
52 + contact.tags = ["vip", "prospect"]
53 + let id = try await dao.insert(contact)
54 + XCTAssertGreaterThan(id, 0)
55 +
56 + var expected = contact
57 + expected.id = id
58 + let fetched = try await dao.getById(id)
59 + XCTAssertEqual(expected, fetched)
60 +
61 + // INSERT OR REPLACE with an existing id replaces, not duplicates.
62 + expected.company = "ACME Corp"
63 + let replacedId = try await dao.insert(expected)
64 + XCTAssertEqual(id, replacedId)
65 + let all = try await dao.getAll()
66 + XCTAssertEqual(1, all.count)
67 + XCTAssertEqual("ACME Corp", all[0].company)
68 + }
69 +
70 + func testUpdateAndFetchAllOrdering() async throws {
71 + let id1 = try await dao.insert(makeContact(fullName: "Ancien", createdAt: 100))
72 + let id3 = try await dao.insert(makeContact(fullName: "Récent", createdAt: 300))
73 + let id2 = try await dao.insert(makeContact(fullName: "Moyen", createdAt: 200))
74 +
75 + let all = try await dao.fetchAll()
76 + XCTAssertEqual([id3, id2, id1], all.map(\.id)) // createdAt DESC
77 +
78 + var updated = try await dao.getById(id2)!
79 + updated.fullName = "Milieu"
80 + updated.updatedAt = 250
81 + try await dao.update(updated)
82 + let fetched = try await dao.getById(id2)
83 + XCTAssertEqual("Milieu", fetched?.fullName)
84 + XCTAssertEqual(250, fetched?.updatedAt)
85 + let count = try await dao.fetchAll().count
86 + XCTAssertEqual(3, count)
87 + }
88 +
89 + // MARK: - serverId lookups
90 +
91 + func testServerIdLookups() async throws {
92 + let id = try await dao.insert(makeContact(fullName: "Ada", createdAt: 10, serverId: "srv-1"))
93 + _ = try await dao.insert(makeContact(fullName: "Grace", createdAt: 20))
94 +
95 + let byServer = try await dao.getByServerId("srv-1")
96 + XCTAssertEqual(id, byServer?.id)
97 + let missing = try await dao.getByServerId("srv-nope")
98 + XCTAssertNil(missing)
99 +
100 + try await dao.deleteByServerId("srv-1")
101 + let afterDelete = try await dao.getByServerId("srv-1")
102 + XCTAssertNil(afterDelete)
103 + let remaining = try await dao.getAll()
104 + XCTAssertEqual(1, remaining.count)
105 + XCTAssertEqual("Grace", remaining[0].fullName)
106 + }
107 +
108 + // MARK: - FTS search
109 +
110 + func testSearchIdsByNamePhoneAndNotes() async throws {
111 + let jean = try await dao.insert(
112 + makeContact(
113 + fullName: "Jean Dupont",
114 + phones: ["0612345678", "0145678901"],
115 + emails: ["jean@exemple.fr"],
116 + notes: "rencontré au salon photo",
117 + createdAt: 100
118 + )
119 + )
120 + let marie = try await dao.insert(
121 + makeContact(
122 + fullName: "Marie Curie",
123 + emails: ["marie@laboratoire.fr"],
124 + notes: "physique nucleaire",
125 + createdAt: 200
126 + )
127 + )
128 +
129 + // Name fragment, prefix matching (repository appends `*` to each token).
130 + let byName = try await dao.searchIds("dup*")
131 + XCTAssertEqual([jean], byName)
132 + // Case-insensitive.
133 + let byNameUpper = try await dao.searchIds("DUPONT*")
134 + XCTAssertEqual([jean], byNameUpper)
135 + // Multi-token = implicit AND.
136 + let byBothTokens = try await dao.searchIds("jean* dupont*")
137 + XCTAssertEqual([jean], byBothTokens)
138 + let noCrossMatch = try await dao.searchIds("jean* curie*")
139 + XCTAssertEqual([], noCrossMatch)
140 +
141 + // Phone fragment via phonesText.
142 + let byPhone = try await dao.searchIds("0612*")
143 + XCTAssertEqual([jean], byPhone)
144 + let byOtherPhone = try await dao.searchIds("0145678901*")
145 + XCTAssertEqual([jean], byOtherPhone)
146 +
147 + // Note fragment.
148 + let byNote = try await dao.searchIds("salon*")
149 + XCTAssertEqual([jean], byNote)
150 + let byNote2 = try await dao.searchIds("nucleaire*")
151 + XCTAssertEqual([marie], byNote2)
152 +
153 + // Email fragment via emailsText.
154 + let byEmail = try await dao.searchIds("laboratoire*")
155 + XCTAssertEqual([marie], byEmail)
156 +
157 + let none = try await dao.searchIds("introuvable*")
158 + XCTAssertEqual([], none)
159 + }
160 +
161 + func testSearchIndexFollowsUpdatesAndDeletes() async throws {
162 + let id = try await dao.insert(makeContact(fullName: "Jean Dupont", createdAt: 100))
163 +
164 + var renamed = try await dao.getById(id)!
165 + renamed.fullName = "Albert Einstein"
166 + try await dao.update(renamed)
167 + let oldName = try await dao.searchIds("dupont*")
168 + XCTAssertEqual([], oldName)
169 + let newName = try await dao.searchIds("einstein*")
170 + XCTAssertEqual([id], newName)
171 +
172 + // REPLACE by id must reindex too.
173 + renamed.fullName = "Isaac Newton"
174 + _ = try await dao.insert(renamed)
175 + let replacedOld = try await dao.searchIds("einstein*")
176 + XCTAssertEqual([], replacedOld)
177 + let replacedNew = try await dao.searchIds("newton*")
178 + XCTAssertEqual([id], replacedNew)
179 +
180 + try await dao.deleteById(id)
181 + let afterDelete = try await dao.searchIds("newton*")
182 + XCTAssertEqual([], afterDelete)
183 + }
184 +
185 + // MARK: - Duplicate exclusions
186 +
187 + func testExclusionsInsertIgnoreAndDelete() async throws {
188 + let first = try await dao.insertExclusion(DuplicateExclusionEntity(idA: 1, idB: 2))
189 + XCTAssertGreaterThan(first, 0)
190 + // Same pair again: OnConflictStrategy.IGNORE returns -1.
191 + let ignored = try await dao.insertExclusion(DuplicateExclusionEntity(idA: 1, idB: 2))
192 + XCTAssertEqual(-1, ignored)
193 + let second = try await dao.insertExclusion(DuplicateExclusionEntity(idA: 2, idB: 3))
194 + XCTAssertGreaterThan(second, 0)
195 +
196 + let exclusions = try await dao.getExclusions()
197 + XCTAssertEqual(2, exclusions.count)
198 +
199 + try await dao.deleteExclusionsFor(1)
200 + let remaining = try await dao.getExclusions()
201 + XCTAssertEqual(1, remaining.count)
202 + XCTAssertEqual(2, remaining[0].idA)
203 + XCTAssertEqual(3, remaining[0].idB)
204 +
205 + try await dao.deleteExclusionsFor(3)
206 + let empty = try await dao.getExclusions()
207 + XCTAssertEqual(0, empty.count)
208 + }
209 +}
A ios/Card2vcfTests/DuplicateDetectorTest.swift
+42 -0
@@ -0,0 +1,42 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class DuplicateDetectorTest: XCTestCase {
5 + private func contact(
6 + _ id: Int64,
7 + emails: [String] = [],
8 + phones: [String] = []
9 + ) -> CrmContactEntity {
10 + CrmContactEntity(id: id, phones: phones, emails: emails)
11 + }
12 +
13 + func testSameEmailSameCluster() {
14 + let a = contact(1, emails: ["Ada@Example.COM"])
15 + let b = contact(2, emails: [" ada@example.com "])
16 + let c = contact(3, emails: ["other@ex.com"])
17 + let clusters = DuplicateDetector.clusters(contacts: [a, b, c], exclusions: [])
18 + XCTAssertEqual(Set([1, 2]), clusters[1])
19 + XCTAssertEqual(Set([1, 2]), clusters[2])
20 + XCTAssertEqual(Set([3]), clusters[3])
21 + XCTAssertEqual(1, DuplicateDetector.duplicateCount(1, clusters: clusters))
22 + XCTAssertEqual(0, DuplicateDetector.duplicateCount(3, clusters: clusters))
23 + }
24 +
25 + func testExclusionSeparatesCluster() {
26 + let a = contact(1, emails: ["same@ex.com"])
27 + let b = contact(2, emails: ["same@ex.com"])
28 + let exclusions = [DuplicateExclusionEntity(idA: 1, idB: 2)]
29 + let clusters = DuplicateDetector.clusters(contacts: [a, b], exclusions: exclusions)
30 + XCTAssertEqual(Set([1]), clusters[1])
31 + XCTAssertEqual(Set([2]), clusters[2])
32 + XCTAssertEqual(0, DuplicateDetector.duplicateCount(1, clusters: clusters))
33 + }
34 +
35 + func testNormalizedPhoneMatch() {
36 + let a = contact(10, phones: ["+33 6 12-34"])
37 + let b = contact(20, phones: ["3361234"])
38 + let clusters = DuplicateDetector.clusters(contacts: [a, b], exclusions: [])
39 + XCTAssertEqual(Set([10, 20]), clusters[10])
40 + XCTAssertTrue(DuplicateDetector.duplicateCount(10, clusters: clusters) == 1)
41 + }
42 +}
A ios/Card2vcfTests/FakeAilianceApi.swift
+134 -0
@@ -0,0 +1,134 @@
1 +import Foundation
2 +@testable import Card2vcf
3 +
4 +/// Configurable fake `AilianceApi`; everything answers empty Ok by default.
5 +/// Shared by `SyncEngineTest` and `AgendaSyncEngineTest`.
6 +final class FakeAilianceApi: AilianceApi {
7 + var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
8 + .ok(SyncStatusResponse(serverTime: "1970-01-01T00:00:00Z", changes: SyncChanges(), total: 0))
9 + var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
10 + .ok(SyncPullResponse(serverTime: "1970-01-01T00:00:00Z"))
11 + var createContactResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"srv-generated"}"#)
12 + var createContactCalls: [String] = []
13 +
14 + var createReservationResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"resa-generated"}"#)
15 + var updateReservationResult: AilianceApiClient.ApiResult<String> = .ok("{}")
16 + var createReservationCalls: [(String, String, String)] = []
17 + var deleteReservationCalls: [(String, String, String)] = []
18 +
19 + var createRdvResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"rdv-generated"}"#)
20 + var createRdvCalls: [String] = []
21 +
22 + var statusQueries: [String?] = []
23 + var pullQueries: [String?] = []
24 +
25 + var uploadCarteResult: AilianceApiClient.ApiResult<String> = .ok("{}")
26 + var uploadPhotoResult: AilianceApiClient.ApiResult<String> = .ok("{}")
27 + var uploadCarteCalls: [(String, Int)] = []
28 + var uploadPhotoCalls: [(String, Int)] = []
29 + var downloadCarteResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent")
30 + var downloadPhotoResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent")
31 + var downloadCarteCalls: [String] = []
32 + var downloadPhotoCalls: [String] = []
33 +
34 + func authCle(nom: String, motDePasse: String) async -> AilianceApiClient.ApiResult<AuthCleResponse> {
35 + .ok(AuthCleResponse(nom: nom, cleChiffree: "", sel: "", kdf: "argon2id", cipher: "xchacha20poly1305"))
36 + }
37 +
38 + func syncStatus(sinceIso: String, ressourcesQuery: String?) async -> AilianceApiClient.ApiResult<SyncStatusResponse> {
39 + statusQueries.append(ressourcesQuery)
40 + return statusResult
41 + }
42 +
43 + func syncPull(sinceIso: String, ressourcesQuery: String?) async -> AilianceApiClient.ApiResult<SyncPullResponse> {
44 + pullQueries.append(ressourcesQuery)
45 + return pullResult
46 + }
47 +
48 + func createContact(jsonBody: String) async -> AilianceApiClient.ApiResult<String> {
49 + createContactCalls.append(jsonBody)
50 + return createContactResult
51 + }
52 +
53 + func updateContact(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
54 + func deleteContact(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
55 + func createEntreprise(jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
56 + func updateEntreprise(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
57 + func deleteEntreprise(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
58 + func createProjet(jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
59 + func updateProjet(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
60 + func deleteProjet(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
61 + func createTache(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
62 + func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
63 + func deleteTache(projetId: String, tacheId: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
64 + func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
65 + func createInteraction(contactId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
66 + func listRdv() async -> AilianceApiClient.ApiResult<[RendezVousDto]> { .ok([]) }
67 +
68 + func createRdv(jsonBody: String) async -> AilianceApiClient.ApiResult<String> {
69 + createRdvCalls.append(jsonBody)
70 + return createRdvResult
71 + }
72 +
73 + func getRdv(id: String) async -> AilianceApiClient.ApiResult<RendezVousDto> {
74 + fatalError("not used")
75 + }
76 +
77 + func updateRdv(id: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
78 + func deleteRdv(id: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
79 + func getRessourcesCatalogue() async -> AilianceApiClient.ApiResult<RessourcesCatalogueDto> { .ok(RessourcesCatalogueDto()) }
80 + func listReservations(genre: String, resourceId: String) async -> AilianceApiClient.ApiResult<[ReservationDto]> { .ok([]) }
81 +
82 + func createReservation(genre: String, resourceId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> {
83 + createReservationCalls.append((genre, resourceId, jsonBody))
84 + return createReservationResult
85 + }
86 +
87 + func updateReservation(
88 + genre: String,
89 + resourceId: String,
90 + reservationId: String,
91 + jsonBody: String
92 + ) async -> AilianceApiClient.ApiResult<String> {
93 + updateReservationResult
94 + }
95 +
96 + func deleteReservation(genre: String, resourceId: String, reservationId: String) async -> AilianceApiClient.ApiResult<Void> {
97 + deleteReservationCalls.append((genre, resourceId, reservationId))
98 + return .ok(())
99 + }
100 +
101 + func listIndisponibilites(genre: String, resourceId: String) async -> AilianceApiClient.ApiResult<[IndisponibiliteDto]> {
102 + .ok([])
103 + }
104 +
105 + func uploadContactCarte(
106 + id: String,
107 + bytes: Data,
108 + filename: String,
109 + contentType: String
110 + ) async -> AilianceApiClient.ApiResult<String> {
111 + uploadCarteCalls.append((id, bytes.count))
112 + return uploadCarteResult
113 + }
114 +
115 + func uploadContactPhoto(
116 + id: String,
117 + bytes: Data,
118 + filename: String,
119 + contentType: String
120 + ) async -> AilianceApiClient.ApiResult<String> {
121 + uploadPhotoCalls.append((id, bytes.count))
122 + return uploadPhotoResult
123 + }
124 +
125 + func downloadContactCarte(id: String) async -> AilianceApiClient.ApiResult<Data> {
126 + downloadCarteCalls.append(id)
127 + return downloadCarteResult
128 + }
129 +
130 + func downloadContactPhoto(id: String) async -> AilianceApiClient.ApiResult<Data> {
131 + downloadPhotoCalls.append(id)
132 + return downloadPhotoResult
133 + }
134 +}
A ios/Card2vcfTests/FakeCalendarBridge.swift
+56 -0
@@ -0,0 +1,56 @@
1 +import Foundation
2 +@testable import Card2vcf
3 +
4 +/// In-memory fake `CalendarBridge` (no EventKit) to test the calendar↔store bridge.
5 +final class FakeCalendarBridge: CalendarBridge {
6 + private var calendars: [String: Int64] = [:]
7 + private var events: [Int64: [Int64: CalendarEventSnapshot]] = [:]
8 + private var nextCalendarId: Int64 = 1
9 + private var nextEventId: Int64 = 1
10 + var deletedEventIds: [Int64] = []
11 +
12 + @discardableResult
13 + func ensureLocalCalendar(displayName: String) throws -> Int64 {
14 + if let existing = calendars[displayName] { return existing }
15 + let id = nextCalendarId
16 + nextCalendarId += 1
17 + calendars[displayName] = id
18 + return id
19 + }
20 +
21 + func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] {
22 + guard let calendarEvents = events[calendarId] else { return [] }
23 + return calendarEvents.keys.sorted().compactMap { calendarEvents[$0] }
24 + }
25 +
26 + @discardableResult
27 + func upsertEvent(calendarId: Int64, snap: CalendarEventSnapshot) throws -> Int64 {
28 + var calendarEvents = events[calendarId] ?? [:]
29 + let eventId: Int64
30 + if let existing = snap.eventId {
31 + eventId = existing
32 + } else {
33 + eventId = nextEventId
34 + nextEventId += 1
35 + }
36 + var stored = snap
37 + stored.eventId = eventId
38 + calendarEvents[eventId] = stored
39 + events[calendarId] = calendarEvents
40 + return eventId
41 + }
42 +
43 + func deleteEvent(eventId: Int64) throws {
44 + deletedEventIds.append(eventId)
45 + for (calendarId, var calendarEvents) in events {
46 + calendarEvents.removeValue(forKey: eventId)
47 + events[calendarId] = calendarEvents
48 + }
49 + }
50 +
51 + /// Simulates an event already present in the system calendar app (added outside `upsertEvent`).
52 + @discardableResult
53 + func seedEvent(calendarId: Int64, snap: CalendarEventSnapshot) -> Int64 {
54 + (try? upsertEvent(calendarId: calendarId, snap: snap)) ?? -1
55 + }
56 +}
A ios/Card2vcfTests/ImageOrientationTest.swift
+117 -0
@@ -0,0 +1,117 @@
1 +import XCTest
2 +import UIKit
3 +@testable import Card2vcf
4 +
5 +/// Port of android `scan/ImageOrientationTest.kt` (pure logic parts):
6 +/// EXIF rotation, camera fallback, and no-double-rotation guarantees.
7 +/// The real-photo fixture tests (dimo card) stay Android-only.
8 +final class ImageOrientationTest: XCTestCase {
9 +
10 + func testExif6Rotates90AndSwapsDimensions() {
11 + let image = solidImage(width: 200, height: 100, orientation: .right) // EXIF 6
12 + let oriented = ImageOrientation.orient(image)
13 + XCTAssertTrue(oriented.fromExif)
14 + XCTAssertEqual(oriented.appliedRotationDegrees, 90)
15 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
16 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
17 + }
18 +
19 + func testExif3Rotates180WithoutChangingDimensions() {
20 + let image = solidImage(width: 160, height: 80, orientation: .down) // EXIF 3
21 + let oriented = ImageOrientation.orient(image)
22 + XCTAssertTrue(oriented.fromExif)
23 + XCTAssertEqual(oriented.appliedRotationDegrees, 180)
24 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 160)
25 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 80)
26 + }
27 +
28 + func testExif8Rotates270() {
29 + let image = solidImage(width: 120, height: 60, orientation: .left) // EXIF 8
30 + let oriented = ImageOrientation.orient(image)
31 + XCTAssertEqual(oriented.appliedRotationDegrees, 270)
32 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 60)
33 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 120)
34 + }
35 +
36 + func testWithoutExifUsesCameraRotation() {
37 + let image = solidImage(width: 200, height: 100, orientation: .up)
38 + let oriented = ImageOrientation.orient(image, cameraRotationDegrees: 90)
39 + XCTAssertFalse(oriented.fromExif)
40 + XCTAssertEqual(oriented.appliedRotationDegrees, 90)
41 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
42 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
43 + }
44 +
45 + func testExifWinsOverCameraNoDoubleRotation() {
46 + let image = solidImage(width: 200, height: 100, orientation: .right)
47 + // If EXIF and camera rotations were cumulated we would get 180° and the
48 + // original dimensions back. That must never happen.
49 + let oriented = ImageOrientation.orient(image, cameraRotationDegrees: 90)
50 + XCTAssertTrue(oriented.fromExif)
51 + XCTAssertEqual(oriented.appliedRotationDegrees, 90)
52 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
53 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
54 + }
55 +
56 + func testNormalizeDegreesQuantizesToQuarterTurns() {
57 + XCTAssertEqual(ImageOrientation.normalizeDegrees(10), 0)
58 + XCTAssertEqual(ImageOrientation.normalizeDegrees(95), 90)
59 + XCTAssertEqual(ImageOrientation.normalizeDegrees(190), 180)
60 + XCTAssertEqual(ImageOrientation.normalizeDegrees(-90), 270)
61 + }
62 +
63 + func testExifRotationDegreesMapping() {
64 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.right), 90)
65 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.leftMirrored), 90)
66 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.down), 180)
67 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.downMirrored), 180)
68 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.left), 270)
69 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.rightMirrored), 270)
70 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.up), 0)
71 + XCTAssertEqual(ImageOrientation.exifRotationDegrees(.upMirrored), 0)
72 + }
73 +
74 + func testJpegBytesWithoutExifUseCameraFallback() throws {
75 + let jpeg = try XCTUnwrap(
76 + solidImage(width: 200, height: 100, orientation: .up).jpegData(compressionQuality: 0.9)
77 + )
78 + let oriented = try XCTUnwrap(
79 + ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees: 90)
80 + )
81 + XCTAssertFalse(oriented.fromExif)
82 + XCTAssertEqual(oriented.appliedRotationDegrees, 90)
83 + XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
84 + XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
85 + }
86 +
87 + // MARK: - Helpers
88 +
89 + /// Solid image whose *pixel* buffer is width x height, tagged with the given
90 + /// orientation (equivalent of the Kotlin JPEG + EXIF tag fixture).
91 + private func solidImage(
92 + width: Int,
93 + height: Int,
94 + orientation: UIImage.Orientation
95 + ) -> UIImage {
96 + let format = UIGraphicsImageRendererFormat.default()
97 + format.scale = 1
98 + let size = CGSize(width: width, height: height)
99 + let base = UIGraphicsImageRenderer(size: size, format: format).image { context in
100 + UIColor.red.setFill()
101 + context.fill(CGRect(origin: .zero, size: size))
102 + UIColor.black.setFill()
103 + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) // corner marker
104 + }
105 + guard let cgImage = base.cgImage else {
106 + preconditionFailure("failed to build test image")
107 + }
108 + return UIImage(cgImage: cgImage, scale: 1, orientation: orientation)
109 + }
110 +
111 + private func pixelSize(_ image: UIImage) -> (width: Int, height: Int) {
112 + guard let cgImage = image.cgImage else {
113 + return (Int(image.size.width), Int(image.size.height))
114 + }
115 + return (cgImage.width, cgImage.height)
116 + }
117 +}
A ios/Card2vcfTests/InMemoryCrmDatabase.swift
+458 -0
@@ -0,0 +1,458 @@
1 +import Foundation
2 +@testable import Card2vcf
3 +
4 +/// In-memory `CrmDatabase` double (Android tests used a Room in-memory database).
5 +/// Reproduces the Room semantics documented on each DAO port.
6 +final class InMemoryCrmDatabase: CrmDatabase {
7 + let contactDaoImpl = InMemoryCrmContactDao()
8 + let entrepriseDaoImpl = InMemoryEntrepriseDao()
9 + let projetDaoImpl = InMemoryProjetDao()
10 + let tacheDaoImpl = InMemoryTacheDao()
11 + let interactionDaoImpl = InMemoryInteractionDao()
12 + let workflowDaoImpl = InMemoryWorkflowDao()
13 + let rdvDaoImpl = InMemoryRdvDao()
14 + let reservationDaoImpl = InMemoryReservationDao()
15 + let indisponibiliteDaoImpl = InMemoryIndisponibiliteDao()
16 + let syncMetaDaoImpl = InMemorySyncMetaDao()
17 + let syncOpDaoImpl = InMemorySyncOpDao()
18 +
19 + var crmContactDao: CrmContactDao { contactDaoImpl }
20 + var entrepriseDao: EntrepriseDao { entrepriseDaoImpl }
21 + var projetDao: ProjetDao { projetDaoImpl }
22 + var tacheDao: TacheDao { tacheDaoImpl }
23 + var interactionDao: InteractionDao { interactionDaoImpl }
24 + var workflowDao: WorkflowDao { workflowDaoImpl }
25 + var rdvDao: RdvDao { rdvDaoImpl }
26 + var reservationDao: ReservationDao { reservationDaoImpl }
27 + var indisponibiliteDao: IndisponibiliteDao { indisponibiliteDaoImpl }
28 + var syncMetaDao: SyncMetaDao { syncMetaDaoImpl }
29 + var syncOpDao: SyncOpDao { syncOpDaoImpl }
30 +}
31 +
32 +final class InMemoryCrmContactDao: CrmContactDao {
33 + private var rows: [CrmContactEntity] = []
34 + private var exclusions: [DuplicateExclusionEntity] = []
35 + private var nextId: Int64 = 1
36 +
37 + @discardableResult
38 + func insert(_ entity: CrmContactEntity) async throws -> Int64 {
39 + var row = entity
40 + if row.id == 0 {
41 + row.id = nextId
42 + nextId += 1
43 + } else {
44 + rows.removeAll { $0.id == row.id }
45 + nextId = max(nextId, row.id + 1)
46 + }
47 + rows.append(row)
48 + return row.id
49 + }
50 +
51 + func update(_ entity: CrmContactEntity) async throws {
52 + if let index = rows.firstIndex(where: { $0.id == entity.id }) {
53 + rows[index] = entity
54 + }
55 + }
56 +
57 + func deleteById(_ id: Int64) async throws {
58 + rows.removeAll { $0.id == id }
59 + }
60 +
61 + func getById(_ id: Int64) async throws -> CrmContactEntity? {
62 + rows.first { $0.id == id }
63 + }
64 +
65 + func getByServerId(_ serverId: String) async throws -> CrmContactEntity? {
66 + rows.first { $0.serverId == serverId }
67 + }
68 +
69 + func deleteByServerId(_ serverId: String) async throws {
70 + rows.removeAll { $0.serverId == serverId }
71 + }
72 +
73 + func fetchAll() async throws -> [CrmContactEntity] {
74 + rows.sorted { $0.createdAt > $1.createdAt }
75 + }
76 +
77 + func getAll() async throws -> [CrmContactEntity] {
78 + try await fetchAll()
79 + }
80 +
81 + func searchIds(_ query: String) async throws -> [Int64] {
82 + []
83 + }
84 +
85 + @discardableResult
86 + func insertExclusion(_ ex: DuplicateExclusionEntity) async throws -> Int64 {
87 + if exclusions.contains(where: { $0.idA == ex.idA && $0.idB == ex.idB }) { return -1 }
88 + var row = ex
89 + row.id = nextId
90 + nextId += 1
91 + exclusions.append(row)
92 + return row.id
93 + }
94 +
95 + func getExclusions() async throws -> [DuplicateExclusionEntity] {
96 + exclusions
97 + }
98 +
99 + func deleteExclusionsFor(_ id: Int64) async throws {
100 + exclusions.removeAll { $0.idA == id || $0.idB == id }
101 + }
102 +}
103 +
104 +final class InMemoryEntrepriseDao: EntrepriseDao {
105 + private var rows: [EntrepriseEntity] = []
106 + private var nextId: Int64 = 1
107 +
108 + @discardableResult
109 + func upsert(_ entity: EntrepriseEntity) async throws -> Int64 {
110 + var row = entity
111 + if row.localId == 0 {
112 + row.localId = nextId
113 + nextId += 1
114 + } else {
115 + rows.removeAll { $0.localId == row.localId }
116 + nextId = max(nextId, row.localId + 1)
117 + }
118 + rows.append(row)
119 + return row.localId
120 + }
121 +
122 + func getByServerId(_ serverId: String) async throws -> EntrepriseEntity? {
123 + rows.first { $0.serverId == serverId }
124 + }
125 +
126 + func getByLocalId(_ localId: Int64) async throws -> EntrepriseEntity? {
127 + rows.first { $0.localId == localId }
128 + }
129 +
130 + func deleteByServerId(_ serverId: String) async throws {
131 + rows.removeAll { $0.serverId == serverId }
132 + }
133 +
134 + func listAll() async throws -> [EntrepriseEntity] {
135 + rows
136 + }
137 +}
138 +
139 +final class InMemoryProjetDao: ProjetDao {
140 + private var rows: [ProjetEntity] = []
141 + private var nextId: Int64 = 1
142 +
143 + @discardableResult
144 + func upsert(_ entity: ProjetEntity) async throws -> Int64 {
145 + var row = entity
146 + if row.localId == 0 {
147 + row.localId = nextId
148 + nextId += 1
149 + } else {
150 + rows.removeAll { $0.localId == row.localId }
151 + nextId = max(nextId, row.localId + 1)
152 + }
153 + rows.append(row)
154 + return row.localId
155 + }
156 +
157 + func getByServerId(_ serverId: String) async throws -> ProjetEntity? {
158 + rows.first { $0.serverId == serverId }
159 + }
160 +
161 + func getByLocalId(_ localId: Int64) async throws -> ProjetEntity? {
162 + rows.first { $0.localId == localId }
163 + }
164 +
165 + func deleteByServerId(_ serverId: String) async throws {
166 + rows.removeAll { $0.serverId == serverId }
167 + }
168 +
169 + func listAll() async throws -> [ProjetEntity] {
170 + rows
171 + }
172 +
173 + func fetchAll() async throws -> [ProjetEntity] {
174 + rows.sorted { $0.nom < $1.nom }
175 + }
176 +}
177 +
178 +final class InMemoryTacheDao: TacheDao {
179 + private var rows: [TacheEntity] = []
180 + private var nextId: Int64 = 1
181 +
182 + @discardableResult
183 + func upsert(_ entity: TacheEntity) async throws -> Int64 {
184 + var row = entity
185 + if row.localId == 0 {
186 + row.localId = nextId
187 + nextId += 1
188 + } else {
189 + rows.removeAll { $0.localId == row.localId }
190 + nextId = max(nextId, row.localId + 1)
191 + }
192 + rows.append(row)
193 + return row.localId
194 + }
195 +
196 + func getByServerId(_ serverId: String) async throws -> TacheEntity? {
197 + rows.first { $0.serverId == serverId }
198 + }
199 +
200 + func getByLocalId(_ localId: Int64) async throws -> TacheEntity? {
201 + rows.first { $0.localId == localId }
202 + }
203 +
204 + func deleteByServerId(_ serverId: String) async throws {
205 + rows.removeAll { $0.serverId == serverId }
206 + }
207 +
208 + func deleteByLocalId(_ localId: Int64) async throws {
209 + rows.removeAll { $0.localId == localId }
210 + }
211 +
212 + func listByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity] {
213 + rows.filter { $0.projetServerId == projetServerId }
214 + }
215 +
216 + func fetchByProjetServerId(_ projetServerId: String) async throws -> [TacheEntity] {
217 + rows.filter { $0.projetServerId == projetServerId }.sorted { $0.ordre < $1.ordre }
218 + }
219 +
220 + func listAll() async throws -> [TacheEntity] {
221 + rows
222 + }
223 +}
224 +
225 +final class InMemoryInteractionDao: InteractionDao {
226 + private var rows: [InteractionEntity] = []
227 + private var nextId: Int64 = 1
228 +
229 + @discardableResult
230 + func upsert(_ entity: InteractionEntity) async throws -> Int64 {
231 + var row = entity
232 + if row.localId == 0 {
233 + row.localId = nextId
234 + nextId += 1
235 + } else {
236 + rows.removeAll { $0.localId == row.localId }
237 + nextId = max(nextId, row.localId + 1)
238 + }
239 + rows.append(row)
240 + return row.localId
241 + }
242 +
243 + func getByServerId(_ serverId: String) async throws -> InteractionEntity? {
244 + rows.first { $0.serverId == serverId }
245 + }
246 +
247 + func deleteByServerId(_ serverId: String) async throws {
248 + rows.removeAll { $0.serverId == serverId }
249 + }
250 +
251 + func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
252 + rows.filter { $0.contactServerId == contactServerId }
253 + }
254 +
255 + func fetchByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
256 + rows.filter { $0.contactServerId == contactServerId }.sorted { $0.createdAt > $1.createdAt }
257 + }
258 +
259 + func listAll() async throws -> [InteractionEntity] {
260 + rows
261 + }
262 +}
263 +
264 +final class InMemoryWorkflowDao: WorkflowDao {
265 + private var rows: [String: WorkflowEntity] = [:]
266 +
267 + func upsert(_ entity: WorkflowEntity) async throws {
268 + rows[entity.serverId] = entity
269 + }
270 +
271 + func getByServerId(_ serverId: String) async throws -> WorkflowEntity? {
272 + rows[serverId]
273 + }
274 +
275 + func deleteByServerId(_ serverId: String) async throws {
276 + rows.removeValue(forKey: serverId)
277 + }
278 +
279 + func listAll() async throws -> [WorkflowEntity] {
280 + Array(rows.values)
281 + }
282 +}
283 +
284 +final class InMemoryRdvDao: RdvDao {
285 + private var rows: [RdvEntity] = []
286 + private var nextId: Int64 = 1
287 +
288 + @discardableResult
289 + func upsert(_ entity: RdvEntity) async throws -> Int64 {
290 + var row = entity
291 + if row.localId == 0 {
292 + row.localId = nextId
293 + nextId += 1
294 + } else {
295 + rows.removeAll { $0.localId == row.localId }
296 + nextId = max(nextId, row.localId + 1)
297 + }
298 + rows.append(row)
299 + return row.localId
300 + }
301 +
302 + func getByServerId(_ serverId: String) async throws -> RdvEntity? {
303 + rows.first { $0.serverId == serverId }
304 + }
305 +
306 + func getByLocalId(_ localId: Int64) async throws -> RdvEntity? {
307 + rows.first { $0.localId == localId }
308 + }
309 +
310 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> RdvEntity? {
311 + rows.first { $0.calendarEventId == calendarEventId }
312 + }
313 +
314 + func listDirty() async throws -> [RdvEntity] {
315 + rows.filter { $0.dirtyLocal }
316 + }
317 +
318 + func deleteByServerId(_ serverId: String) async throws {
319 + rows.removeAll { $0.serverId == serverId }
320 + }
321 +
322 + func deleteByLocalId(_ localId: Int64) async throws {
323 + rows.removeAll { $0.localId == localId }
324 + }
325 +
326 + func listAll() async throws -> [RdvEntity] {
327 + rows
328 + }
329 +}
330 +
331 +final class InMemoryReservationDao: ReservationDao {
332 + private var rows: [ReservationEntity] = []
333 + private var nextId: Int64 = 1
334 +
335 + @discardableResult
336 + func upsert(_ entity: ReservationEntity) async throws -> Int64 {
337 + var row = entity
338 + if row.localId == 0 {
339 + row.localId = nextId
340 + nextId += 1
341 + } else {
342 + rows.removeAll { $0.localId == row.localId }
343 + nextId = max(nextId, row.localId + 1)
344 + }
345 + rows.append(row)
346 + return row.localId
347 + }
348 +
349 + func getByServerId(_ serverId: String) async throws -> ReservationEntity? {
350 + rows.first { $0.serverId == serverId }
351 + }
352 +
353 + func getByLocalId(_ localId: Int64) async throws -> ReservationEntity? {
354 + rows.first { $0.localId == localId }
355 + }
356 +
357 + func getByCalendarEventId(_ calendarEventId: Int64) async throws -> ReservationEntity? {
358 + rows.first { $0.calendarEventId == calendarEventId }
359 + }
360 +
361 + func listDirty() async throws -> [ReservationEntity] {
362 + rows.filter { $0.dirtyLocal }
363 + }
364 +
365 + func deleteByServerId(_ serverId: String) async throws {
366 + rows.removeAll { $0.serverId == serverId }
367 + }
368 +
369 + func deleteByLocalId(_ localId: Int64) async throws {
370 + rows.removeAll { $0.localId == localId }
371 + }
372 +
373 + func listByCible(cibleType: String, cibleId: String) async throws -> [ReservationEntity] {
374 + rows.filter { $0.cibleType == cibleType && $0.cibleId == cibleId }
375 + }
376 +
377 + func listAll() async throws -> [ReservationEntity] {
378 + rows
379 + }
380 +}
381 +
382 +final class InMemoryIndisponibiliteDao: IndisponibiliteDao {
383 + private var rows: [IndisponibiliteEntity] = []
384 + private var nextId: Int64 = 1
385 +
386 + @discardableResult
387 + func upsert(_ entity: IndisponibiliteEntity) async throws -> Int64 {
388 + var row = entity
389 + if row.localId == 0 {
390 + row.localId = nextId
391 + nextId += 1
392 + } else {
393 + rows.removeAll { $0.localId == row.localId }
394 + nextId = max(nextId, row.localId + 1)
395 + }
396 + rows.append(row)
397 + return row.localId
398 + }
399 +
400 + func getByServerId(_ serverId: String) async throws -> IndisponibiliteEntity? {
401 + rows.first { $0.serverId == serverId }
402 + }
403 +
404 + func deleteByServerId(_ serverId: String) async throws {
405 + rows.removeAll { $0.serverId == serverId }
406 + }
407 +
408 + func listByCible(cibleType: String, cibleId: String) async throws -> [IndisponibiliteEntity] {
409 + rows.filter { $0.cibleType == cibleType && $0.cibleId == cibleId }
410 + }
411 +
412 + func listAll() async throws -> [IndisponibiliteEntity] {
413 + rows
414 + }
415 +}
416 +
417 +final class InMemorySyncMetaDao: SyncMetaDao {
418 + private var rows: [String: SyncMetaEntity] = [:]
419 +
420 + func upsert(_ entity: SyncMetaEntity) async throws {
421 + rows[entity.key] = entity
422 + }
423 +
424 + func get(_ key: String) async throws -> SyncMetaEntity? {
425 + rows[key]
426 + }
427 +}
428 +
429 +final class InMemorySyncOpDao: SyncOpDao {
430 + private var rows: [SyncOpEntity] = []
431 + private var nextId: Int64 = 1
432 +
433 + @discardableResult
434 + func insert(_ entity: SyncOpEntity) async throws -> Int64 {
435 + var row = entity
436 + if row.id == 0 {
437 + row.id = nextId
438 + nextId += 1
439 + } else {
440 + rows.removeAll { $0.id == row.id }
441 + nextId = max(nextId, row.id + 1)
442 + }
443 + rows.append(row)
444 + return row.id
445 + }
446 +
447 + func listAll() async throws -> [SyncOpEntity] {
448 + rows.sorted { $0.createdAt < $1.createdAt }
449 + }
450 +
451 + func getById(_ id: Int64) async throws -> SyncOpEntity? {
452 + rows.first { $0.id == id }
453 + }
454 +
455 + func deleteById(_ id: Int64) async throws {
456 + rows.removeAll { $0.id == id }
457 + }
458 +}
A ios/Card2vcfTests/KanbanFilterTest.swift
+44 -0
@@ -0,0 +1,44 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class KanbanFilterTest: XCTestCase {
5 + private func tache(_ localId: Int64, _ assigneA: String? = nil) -> TacheEntity {
6 + TacheEntity(
7 + localId: localId,
8 + titre: "T\(localId)",
9 + assigneA: assigneA
10 + )
11 + }
12 +
13 + func testMine_keepsOnlyTasksAssignedToUser() {
14 + let tasks = [
15 + tache(1, "alice"),
16 + tache(2, "bob"),
17 + tache(3, nil),
18 + ]
19 +
20 + let result = KanbanFilter.filter(tasks, mode: .mine, userName: "alice")
21 +
22 + XCTAssertEqual([tasks[0]], result)
23 + }
24 +
25 + func testAll_keepsEveryTaskIncludingUnassigned() {
26 + let tasks = [
27 + tache(1, "alice"),
28 + tache(2, "bob"),
29 + tache(3, nil),
30 + ]
31 +
32 + let result = KanbanFilter.filter(tasks, mode: .all, userName: "alice")
33 +
34 + XCTAssertEqual(tasks, result)
35 + }
36 +
37 + func testMine_excludesUnassignedTasks() {
38 + let tasks = [tache(1, nil)]
39 +
40 + let result = KanbanFilter.filter(tasks, mode: .mine, userName: "alice")
41 +
42 + XCTAssertEqual([], result)
43 + }
44 +}
A ios/Card2vcfTests/LwwMergerTest.swift
+82 -0
@@ -0,0 +1,82 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class LwwMergerTest: XCTestCase {
5 + func testPickLww_remoteNewerWins() {
6 + let result = LwwMerger.pickLww(
7 + local: "local-value",
8 + localTs: 100,
9 + remote: "remote-value",
10 + remoteTs: 200
11 + )
12 + XCTAssertEqual("remote-value", result)
13 + }
14 +
15 + func testPickLww_localNewerWins() {
16 + let result = LwwMerger.pickLww(
17 + local: "local-value",
18 + localTs: 300,
19 + remote: "remote-value",
20 + remoteTs: 200
21 + )
22 + XCTAssertEqual("local-value", result)
23 + }
24 +
25 + func testPickLww_equalTimestampsRemoteWins() {
26 + let result = LwwMerger.pickLww(
27 + local: "local-value",
28 + localTs: 100,
29 + remote: "remote-value",
30 + remoteTs: 100
31 + )
32 + XCTAssertEqual("remote-value", result)
33 + }
34 +
35 + func testPickTaskMove_remoteNewerWins() {
36 + let local = LwwMerger.TaskPlacement(colonneId: "todo", ordre: 1, updatedAt: 100)
37 + let remote = LwwMerger.TaskPlacement(colonneId: "done", ordre: 2, updatedAt: 200)
38 +
39 + let result = LwwMerger.pickTaskMove(local: local, remote: remote)
40 +
41 + XCTAssertEqual(remote, result)
42 + }
43 +
44 + func testPickTaskMove_localNewerWins() {
45 + let local = LwwMerger.TaskPlacement(colonneId: "todo", ordre: 1, updatedAt: 300)
46 + let remote = LwwMerger.TaskPlacement(colonneId: "done", ordre: 2, updatedAt: 200)
47 +
48 + let result = LwwMerger.pickTaskMove(local: local, remote: remote)
49 +
50 + XCTAssertEqual(local, result)
51 + }
52 +
53 + func testPickTaskMove_equalTimestampsRemoteWins() {
54 + let local = LwwMerger.TaskPlacement(colonneId: "todo", ordre: 1, updatedAt: 100)
55 + let remote = LwwMerger.TaskPlacement(colonneId: "done", ordre: 2, updatedAt: 100)
56 +
57 + let result = LwwMerger.pickTaskMove(local: local, remote: remote)
58 +
59 + XCTAssertEqual(remote, result)
60 + }
61 +
62 + func testShouldInsertInteraction_whenUnknownServerId() {
63 + let existing: Set<String> = ["srv-1", "srv-2"]
64 +
65 + XCTAssertTrue(LwwMerger.shouldInsertInteraction(existingServerIds: existing, remoteServerId: "srv-3"))
66 + }
67 +
68 + func testShouldInsertInteraction_whenKnownServerId() {
69 + let existing: Set<String> = ["srv-1", "srv-2"]
70 +
71 + XCTAssertFalse(LwwMerger.shouldInsertInteraction(existingServerIds: existing, remoteServerId: "srv-1"))
72 + }
73 +
74 + func testApplyTombstoneIds_removesMatchingIds() {
75 + let local: Set<String> = ["a", "b", "c"]
76 + let tombstones: Set<String> = ["b", "d"]
77 +
78 + let remaining = LwwMerger.applyTombstoneIds(localServerIds: local, tombstoneIds: tombstones)
79 +
80 + XCTAssertEqual(Set(["a", "c"]), remaining)
81 + }
82 +}
A ios/Card2vcfTests/OcrOrientationTest.swift
+15 -0
@@ -0,0 +1,15 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class OcrOrientationTest: XCTestCase {
5 + func testScorePrefereEmailEtTelephone() {
6 + let weak = OcrResult(rawText: "abc", phones: [], emails: [])
7 + let strong = OcrResult(
8 + rawText: "Jean Paul GENOUX\njpgenoux@dimosoftware.com\n+33674640544",
9 + phones: ["+33674640544"],
10 + emails: ["jpgenoux@dimosoftware.com"],
11 + urls: ["https://www.dimosoftware.fr"]
12 + )
13 + XCTAssertTrue(OcrOrientation.score(strong) > OcrOrientation.score(weak))
14 + }
15 +}
A ios/Card2vcfTests/OcrPostProcessingTest.swift
+47 -0
@@ -0,0 +1,47 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class OcrPostProcessingTest: XCTestCase {
5 + func testExtraitEmailEtTelephone() {
6 + let text = """
7 + Jean Dupont
8 + Directeur
9 + 06 12 34 56 78
10 + jean.dupont@acme.fr
11 + https://acme.fr
12 + """
13 + let r = OcrPostProcessor.process(text)
14 + XCTAssertTrue(r.emails.contains("jean.dupont@acme.fr"))
15 + XCTAssertTrue(r.phones.contains { $0.contains("0612345678") })
16 + XCTAssertTrue(r.urls.contains("https://acme.fr"))
17 + }
18 +
19 + func testMultiEmails() {
20 + let r = OcrPostProcessor.process("a@b.fr et c@d.fr")
21 + XCTAssertEqual(2, r.emails.count)
22 + }
23 +
24 + func testLignesNettoyees() {
25 + let r = OcrPostProcessor.process(" ligne1 \n\n ligne2 ")
26 + XCTAssertEqual(["ligne1", "ligne2"], r.lines)
27 + }
28 +
29 + func testExtraitCarteDimoCommeSurAppareil() {
30 + let text = """
31 + dimo
32 + SOFTWARE
33 + Jean Pau! GENOUX
34 + Directeur Général/CEO
35 + jpgenoux@dimosoftware.com
36 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
37 + Siège Social
38 + 561, alée des Noisetiers - 69 760 Limonest - France
39 + www.dimosoftware.fr
40 + """
41 + let r = OcrPostProcessor.process(text)
42 + XCTAssertEqual(["jpgenoux@dimosoftware.com"], r.emails)
43 + XCTAssertTrue(r.phones.contains { $0 == "+33674640544" || $0.hasSuffix("674640544") })
44 + XCTAssertTrue(r.phones.contains { $0.contains("472860192") || $0.hasSuffix("472860192") })
45 + XCTAssertTrue(r.urls.contains { $0.contains("dimosoftware.fr") })
46 + }
47 +}
A ios/Card2vcfTests/ServerUrlPolicyTest.swift
+59 -0
@@ -0,0 +1,59 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// TDD (Android) : HTTPS required unless the « autoriser HTTP clair » option is on.
5 +final class ServerUrlPolicyTest: XCTestCase {
6 + func testHttpsUrlIsAllowedByDefault() {
7 + XCTAssertNil(ServerUrlPolicy.validate("https://api.example.com", allowCleartext: false))
8 + XCTAssertNil(ServerUrlPolicy.validate("HTTPS://API.EXAMPLE.COM/v1", allowCleartext: false))
9 + }
10 +
11 + func testHttpUrlRejectedWhenCleartextDisallowed() {
12 + let err = ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext: false)
13 + XCTAssertEqual(ServerUrlPolicy.ERROR_HTTPS_REQUIRED, err)
14 + }
15 +
16 + func testHttpUrlAllowedWhenCleartextEnabled() {
17 + XCTAssertNil(ServerUrlPolicy.validate("http://192.168.1.10:3000", allowCleartext: true))
18 + XCTAssertNil(ServerUrlPolicy.validate("http://localhost:3000", allowCleartext: true))
19 + }
20 +
21 + func testBlankOrMissingSchemeRejected() {
22 + XCTAssertEqual(ServerUrlPolicy.ERROR_INVALID_URL, ServerUrlPolicy.validate(" ", allowCleartext: true))
23 + XCTAssertEqual(
24 + ServerUrlPolicy.ERROR_INVALID_URL,
25 + ServerUrlPolicy.validate("api.example.com", allowCleartext: false)
26 + )
27 + XCTAssertEqual(
28 + ServerUrlPolicy.ERROR_INVALID_URL,
29 + ServerUrlPolicy.validate("ftp://api.example.com", allowCleartext: true)
30 + )
31 + }
32 +
33 + func testIsHttpsHelper() {
34 + XCTAssertTrue(ServerUrlPolicy.isHttps("https://x"))
35 + XCTAssertFalse(ServerUrlPolicy.isHttps("http://x"))
36 + XCTAssertFalse(ServerUrlPolicy.isHttps("x"))
37 + }
38 +
39 + func testLanHttpsWithoutPortRejected() {
40 + let err = ServerUrlPolicy.validate("https://192.168.1.35", allowCleartext: false)
41 + XCTAssertEqual(ServerUrlPolicy.ERROR_LAN_HTTPS_NO_PORT, err)
42 + }
43 +
44 + func testLanHttpWithoutPortRejectedEvenWhenCleartext() {
45 + let err = ServerUrlPolicy.validate("http://192.168.1.35", allowCleartext: true)
46 + XCTAssertEqual(ServerUrlPolicy.ERROR_LAN_HTTP_NO_PORT, err)
47 + }
48 +
49 + func testNormalizeBaseUrlStripsVersionPath() {
50 + XCTAssertEqual(
51 + "http://192.168.1.35:3000",
52 + ServerUrlPolicy.normalizeBaseUrl("http://192.168.1.35:3000/version/")
53 + )
54 + }
55 +
56 + func testPublicHttpsWithoutPortStillAllowed() {
57 + XCTAssertNil(ServerUrlPolicy.validate("https://api.example.com", allowCleartext: false))
58 + }
59 +}
A ios/Card2vcfTests/SyncCredentialsStoreTest.swift
+81 -0
@@ -0,0 +1,81 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class SyncCredentialsStoreTest: XCTestCase {
5 + private var store: SyncCredentialsStore!
6 +
7 + override func setUp() {
8 + super.setUp()
9 + // Keychain is not reliably available in the test environment: back the
10 + // store with the injected in-memory storage (same public semantics).
11 + store = SyncCredentialsStore(storage: InMemoryCredentialsStorage())
12 + }
13 +
14 + override func tearDown() {
15 + store.clear()
16 + super.tearDown()
17 + }
18 +
19 + func testInitiallyNotConfigured() {
20 + XCTAssertFalse(store.isConfigured())
21 + XCTAssertNil(store.baseUrl)
22 + XCTAssertNil(store.apiKey)
23 + XCTAssertNil(store.userName)
24 + }
25 +
26 + func testSaveRoundTrip() {
27 + store.save(
28 + baseUrl: "https://api.example.com",
29 + apiKey: "secret-bearer-token",
30 + userName: "alice"
31 + )
32 +
33 + XCTAssertTrue(store.isConfigured())
34 + XCTAssertEqual("https://api.example.com", store.baseUrl)
35 + XCTAssertEqual("secret-bearer-token", store.apiKey)
36 + XCTAssertEqual("alice", store.userName)
37 + }
38 +
39 + func testPropertySettersRoundTrip() {
40 + store.baseUrl = "https://sync.test"
41 + store.apiKey = "key-123"
42 + store.userName = "bob"
43 +
44 + XCTAssertTrue(store.isConfigured())
45 + XCTAssertEqual("https://sync.test", store.baseUrl)
46 + XCTAssertEqual("key-123", store.apiKey)
47 + XCTAssertEqual("bob", store.userName)
48 + }
49 +
50 + func testClearRemovesAllCredentials() {
51 + store.save(
52 + baseUrl: "https://api.example.com",
53 + apiKey: "secret-bearer-token",
54 + userName: "alice"
55 + )
56 + store.allowCleartextHttp = true
57 +
58 + store.clear()
59 +
60 + XCTAssertFalse(store.isConfigured())
61 + XCTAssertNil(store.baseUrl)
62 + XCTAssertNil(store.apiKey)
63 + XCTAssertNil(store.userName)
64 + XCTAssertTrue(store.allowCleartextHttp)
65 + }
66 +
67 + func testAllowCleartextHttp_defaultsFalse_andRoundTrips() {
68 + XCTAssertFalse(store.allowCleartextHttp)
69 + store.allowCleartextHttp = true
70 + XCTAssertTrue(store.allowCleartextHttp)
71 + store.allowCleartextHttp = false
72 + XCTAssertFalse(store.allowCleartextHttp)
73 + }
74 +
75 + func testBlankValuesAreNotConfigured() {
76 + store.save(baseUrl: " ", apiKey: "key", userName: "alice")
77 +
78 + XCTAssertFalse(store.isConfigured())
79 + XCTAssertNil(store.baseUrl)
80 + }
81 +}
A ios/Card2vcfTests/SyncEngineTest.swift
+239 -0
@@ -0,0 +1,239 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class SyncEngineTest: XCTestCase {
5 + private var db: InMemoryCrmDatabase!
6 + private var api: FakeAilianceApi!
7 + private var engine: SyncEngine!
8 + private var imagesRoot: URL!
9 +
10 + override func setUp() {
11 + super.setUp()
12 + db = InMemoryCrmDatabase()
13 + api = FakeAilianceApi()
14 + engine = SyncEngine(api: api, db: db)
15 + imagesRoot = FileManager.default.temporaryDirectory
16 + .appendingPathComponent("SyncEngineTest-\(UUID().uuidString)", isDirectory: true)
17 + }
18 +
19 + override func tearDown() {
20 + try? FileManager.default.removeItem(at: imagesRoot)
21 + super.tearDown()
22 + }
23 +
24 + private func makeImageStore() -> ContactImageStore {
25 + ContactImageStore(rootDirectory: imagesRoot)
26 + }
27 +
28 + func testCheckStatus_returnsTotalFromFake() async throws {
29 + api.statusResult = .ok(
30 + SyncStatusResponse(
31 + serverTime: "2026-07-22T12:00:00Z",
32 + changes: SyncChanges(contacts: 2, tombstones: 1),
33 + total: 3
34 + )
35 + )
36 +
37 + let result = try await engine.checkStatus()
38 +
39 + XCTAssertEqual(3, result.pendingRemoteChanges)
40 + XCTAssertEqual(3, result.response?.total)
41 + }
42 +
43 + func testSyncNow_pullAppliesNewerContact() async throws {
44 + var local = CrmContactEntity()
45 + local.serverId = "c1"
46 + local.fullName = "Old Name"
47 + local.updatedAt = 1_000
48 + _ = try await db.crmContactDao.insert(local)
49 + api.pullResult = .ok(
50 + SyncPullResponse(
51 + serverTime: "2026-07-22T12:05:00Z",
52 + contacts: [
53 + ContactDto(
54 + id: "c1",
55 + prenom: "Jean",
56 + nom: "Dupont",
57 + creeLe: "2026-07-20T10:00:00Z",
58 + misAJourLe: "2026-07-22T11:00:00Z"
59 + ),
60 + ]
61 + )
62 + )
63 +
64 + _ = try await engine.syncNow()
65 +
66 + let updated = try await db.crmContactDao.getByServerId("c1")
67 + XCTAssertEqual("Jean Dupont", updated?.fullName)
68 + }
69 +
70 + func testSyncNow_pullKeepsBothAppendOnlyInteractions() async throws {
71 + api.pullResult = .ok(
72 + SyncPullResponse(
73 + serverTime: "2026-07-22T12:05:00Z",
74 + interactions: [
75 + InteractionDto(id: "i1", contactId: "c1", sujet: "Appel", creeLe: "2026-07-22T09:00:00Z"),
76 + InteractionDto(id: "i2", contactId: "c1", sujet: "Relance", creeLe: "2026-07-22T10:00:00Z"),
77 + ]
78 + )
79 + )
80 +
81 + _ = try await engine.syncNow()
82 +
83 + let stored = try await db.interactionDao.listByContactServerId("c1")
84 + XCTAssertEqual(2, stored.count)
85 + XCTAssertTrue(stored.contains { $0.serverId == "i1" })
86 + XCTAssertTrue(stored.contains { $0.serverId == "i2" })
87 + }
88 +
89 + func testSyncNow_tombstoneDeletesContact() async throws {
90 + var local = CrmContactEntity()
91 + local.serverId = "c-old"
92 + local.fullName = "À supprimer"
93 + local.updatedAt = 500
94 + _ = try await db.crmContactDao.insert(local)
95 + api.pullResult = .ok(
96 + SyncPullResponse(
97 + serverTime: "2026-07-22T12:05:00Z",
98 + tombstones: [
99 + TombstoneDto(entityType: "contact", id: "c-old", supprimeLe: "2026-07-22T12:00:00Z"),
100 + ]
101 + )
102 + )
103 +
104 + _ = try await engine.syncNow()
105 +
106 + let stored = try await db.crmContactDao.getByServerId("c-old")
107 + XCTAssertNil(stored)
108 + }
109 +
110 + func testSyncNow_updatesWatermarkFromServerTime() async throws {
111 + api.pullResult = .ok(SyncPullResponse(serverTime: "2026-07-22T12:05:00Z"))
112 +
113 + let result = try await engine.syncNow()
114 +
115 + XCTAssertTrue(result.success)
116 + XCTAssertEqual("2026-07-22T12:05:00Z", result.serverTime)
117 + let watermark = try await db.syncMetaDao.get("watermark")
118 + XCTAssertEqual("2026-07-22T12:05:00Z", watermark?.value)
119 + }
120 +
121 + func testSyncNow_pushesContactCreateOpAndMapsServerId() async throws {
122 + var contact = CrmContactEntity()
123 + contact.fullName = "Nouveau"
124 + let localId = try await db.crmContactDao.insert(contact)
125 + var op = SyncOpEntity(entityType: "contact", op: "create")
126 + op.payloadJson = #"{"fullName":"Nouveau"}"#
127 + op.localId = localId
128 + op.createdAt = 1
129 + _ = try await db.syncOpDao.insert(op)
130 +
131 + _ = try await engine.syncNow()
132 +
133 + XCTAssertEqual(1, api.createContactCalls.count)
134 + let pending = try await db.syncOpDao.listAll()
135 + XCTAssertEqual(0, pending.count)
136 + let stored = try await db.crmContactDao.getById(localId)
137 + XCTAssertEqual("srv-generated", stored?.serverId)
138 + }
139 +
140 + func testSyncNow_pushesCardImageAfterContactCreate() async throws {
141 + let images = makeImageStore()
142 + engine = SyncEngine(api: api, db: db, imageStore: images)
143 +
144 + var contact = CrmContactEntity()
145 + contact.fullName = "Avec carte"
146 + let localId = try await db.crmContactDao.insert(contact)
147 + let cardPath = try images.saveBytes(contactId: localId, name: "card.jpg", bytes: Data([1, 2, 3, 4]))
148 + var stored = try await db.crmContactDao.getById(localId)!
149 + stored.cardImagePath = cardPath
150 + try await db.crmContactDao.update(stored)
151 + var op = SyncOpEntity(entityType: "contact", op: "create")
152 + op.payloadJson = #"{"prenom":"A","nom":"B"}"#
153 + op.localId = localId
154 + op.createdAt = 1
155 + _ = try await db.syncOpDao.insert(op)
156 +
157 + _ = try await engine.syncNow()
158 +
159 + XCTAssertEqual(1, api.createContactCalls.count)
160 + XCTAssertEqual(1, api.uploadCarteCalls.count)
161 + XCTAssertEqual("srv-generated", api.uploadCarteCalls[0].0)
162 + XCTAssertEqual(4, api.uploadCarteCalls[0].1)
163 + let pending = try await db.syncOpDao.listAll()
164 + XCTAssertEqual(0, pending.count)
165 + }
166 +
167 + func testSyncNow_enqueuesMediaRetryWhenUploadFails() async throws {
168 + let images = makeImageStore()
169 + engine = SyncEngine(api: api, db: db, imageStore: images)
170 + api.uploadCarteResult = .err(code: 500, message: "boom")
171 +
172 + var contact = CrmContactEntity()
173 + contact.fullName = "Retry"
174 + let localId = try await db.crmContactDao.insert(contact)
175 + let cardPath = try images.saveBytes(contactId: localId, name: "card.jpg", bytes: Data([9]))
176 + var stored = try await db.crmContactDao.getById(localId)!
177 + stored.cardImagePath = cardPath
178 + try await db.crmContactDao.update(stored)
179 + var op = SyncOpEntity(entityType: "contact", op: "create")
180 + op.payloadJson = #"{"prenom":"A","nom":"B"}"#
181 + op.localId = localId
182 + op.createdAt = 1
183 + _ = try await db.syncOpDao.insert(op)
184 +
185 + _ = try await engine.syncNow()
186 +
187 + let pending = try await db.syncOpDao.listAll()
188 + XCTAssertEqual(1, pending.count)
189 + XCTAssertEqual("contact_media", pending[0].entityType)
190 + XCTAssertEqual("srv-generated", pending[0].serverId)
191 + }
192 +
193 + func testSyncNow_pullDownloadsMissingCardImage() async throws {
194 + let images = makeImageStore()
195 + engine = SyncEngine(api: api, db: db, imageStore: images)
196 + api.downloadCarteResult = .ok(Data([7, 8, 9]))
197 + api.pullResult = .ok(
198 + SyncPullResponse(
199 + serverTime: "2026-07-22T12:05:00Z",
200 + contacts: [
201 + ContactDto(
202 + id: "c-img",
203 + prenom: "Marie",
204 + nom: "Curie",
205 + creeLe: "2026-07-20T10:00:00Z",
206 + carteVisite: "carte.jpg"
207 + ),
208 + ]
209 + )
210 + )
211 +
212 + _ = try await engine.syncNow()
213 +
214 + XCTAssertEqual(["c-img"], api.downloadCarteCalls)
215 + let stored = try await db.crmContactDao.getByServerId("c-img")
216 + let path = stored?.cardImagePath
217 + XCTAssertNotNil(path)
218 + XCTAssertFalse(path!.isEmpty)
219 + var isDirectory: ObjCBool = false
220 + XCTAssertTrue(FileManager.default.fileExists(atPath: path!, isDirectory: &isDirectory))
221 + XCTAssertFalse(isDirectory.boolValue)
222 + }
223 +
224 + func testSyncNow_skipsImageUploadWhenNoLocalFile() async throws {
225 + var contact = CrmContactEntity()
226 + contact.fullName = "Sans image"
227 + let localId = try await db.crmContactDao.insert(contact)
228 + var op = SyncOpEntity(entityType: "contact", op: "create")
229 + op.payloadJson = #"{"prenom":"A","nom":"B"}"#
230 + op.localId = localId
231 + op.createdAt = 1
232 + _ = try await db.syncOpDao.insert(op)
233 +
234 + _ = try await engine.syncNow()
235 +
236 + XCTAssertEqual(0, api.uploadCarteCalls.count)
237 + XCTAssertEqual(0, api.uploadPhotoCalls.count)
238 + }
239 +}
A ios/Card2vcfTests/SyncSchemaDaoTest.swift
+79 -0
@@ -0,0 +1,79 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port of android `sync/SyncSchemaDaoTest.kt` against an in-memory database.
5 +final class SyncSchemaDaoTest: XCTestCase {
6 + private var db: Card2vcfDatabase!
7 +
8 + override func setUp() {
9 + super.setUp()
10 + db = Card2vcfDatabase(inMemory: true)
11 + }
12 +
13 + override func tearDown() {
14 + db = nil
15 + super.tearDown()
16 + }
17 +
18 + func testSyncOpInsertAndList() async throws {
19 + let dao = db.syncOpDao
20 + let id = try await dao.insert(
21 + SyncOpEntity(
22 + entityType: "contact",
23 + op: "create",
24 + payloadJson: #"{"fullName":"Ada"}"#,
25 + localId: 42,
26 + createdAt: 1000
27 + )
28 + )
29 + let ops = try await dao.listAll()
30 + XCTAssertEqual(1, ops.count)
31 + XCTAssertEqual(id, ops[0].id)
32 + XCTAssertEqual("contact", ops[0].entityType)
33 + XCTAssertEqual("create", ops[0].op)
34 + XCTAssertEqual(42, ops[0].localId)
35 + let fetched = try await dao.getById(id)
36 + XCTAssertNotNil(fetched)
37 + try await dao.deleteById(id)
38 + let afterDelete = try await dao.getById(id)
39 + XCTAssertNil(afterDelete)
40 + let empty = try await dao.listAll()
41 + XCTAssertEqual(0, empty.count)
42 + }
43 +
44 + func testTacheUpsertAndGetByServerId() async throws {
45 + let dao = db.tacheDao
46 + let localId = try await dao.upsert(
47 + TacheEntity(
48 + serverId: "tache-1",
49 + projetServerId: "projet-1",
50 + titre: "Préparer démo",
51 + colonneId: "col-a",
52 + ordre: 2,
53 + assigneA: "alice",
54 + auteur: "bob",
55 + createdAt: 500,
56 + updatedAt: 600
57 + )
58 + )
59 + var found = try await dao.getByServerId("tache-1")
60 + XCTAssertNotNil(found)
61 + XCTAssertEqual(localId, found!.localId)
62 + XCTAssertEqual("Préparer démo", found!.titre)
63 + XCTAssertEqual("projet-1", found!.projetServerId)
64 + XCTAssertEqual(2, found!.ordre)
65 + XCTAssertEqual("alice", found!.assigneA)
66 +
67 + found!.titre = "Démo prête"
68 + found!.updatedAt = 700
69 + try await dao.upsert(found!)
70 + let updated = try await dao.getByServerId("tache-1")
71 + XCTAssertEqual("Démo prête", updated!.titre)
72 +
73 + let byProjet = try await dao.listByProjetServerId("projet-1")
74 + XCTAssertEqual(1, byProjet.count)
75 + try await dao.deleteByServerId("tache-1")
76 + let afterDelete = try await dao.getByServerId("tache-1")
77 + XCTAssertNil(afterDelete)
78 + }
79 +}
A ios/Card2vcfTests/VCardParserTest.swift
+107 -0
@@ -0,0 +1,107 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class VCardParserTest: XCTestCase {
5 +
6 + // Android loaded these from test resources (vcf/sample3.vcf, vcf/sample4.vcf);
7 + // they are inlined here to keep the test bundle resource-free.
8 + private func loadFixture(_ name: String) -> String {
9 + switch name {
10 + case "sample3.vcf":
11 + return """
12 + BEGIN:VCARD
13 + VERSION:3.0
14 + FN:Alice Martin
15 + N:Martin;Alice;;;
16 + ORG:Acme
17 + EMAIL;TYPE=INTERNET:alice@acme.test
18 + TEL;TYPE=CELL:+33601020304
19 + NOTE:salon Lyon
20 + END:VCARD
21 + BEGIN:VCARD
22 + VERSION:3.0
23 + FN:Bob
24 + EMAIL:bob@unique.test
25 + END:VCARD
26 + """
27 + case "sample4.vcf":
28 + return """
29 + BEGIN:VCARD
30 + VERSION:4.0
31 + FN:Alice V4
32 + N:V4;Alice;;;
33 + EMAIL;TYPE=work:alice@acme.test
34 + TEL;TYPE=cell:+33601020304
35 + END:VCARD
36 + """
37 + default:
38 + fatalError("Unknown fixture: \(name)")
39 + }
40 + }
41 +
42 + func testParseSample3() {
43 + let cards = VCardParser.parse(loadFixture("sample3.vcf"))
44 + XCTAssertEqual(2, cards.count)
45 +
46 + let alice = cards[0]
47 + XCTAssertEqual("Alice Martin", alice.fullName)
48 + XCTAssertEqual("Alice", alice.firstName)
49 + XCTAssertEqual("Martin", alice.lastName)
50 + XCTAssertEqual("Acme", alice.company)
51 + XCTAssertEqual(["alice@acme.test"], alice.emails)
52 + XCTAssertEqual(["+33601020304"], alice.phones)
53 + XCTAssertEqual("salon Lyon", alice.note)
54 +
55 + let bob = cards[1]
56 + XCTAssertEqual("Bob", bob.fullName)
57 + XCTAssertEqual(["bob@unique.test"], bob.emails)
58 + }
59 +
60 + func testParseSample4() {
61 + let cards = VCardParser.parse(loadFixture("sample4.vcf"))
62 + XCTAssertEqual(1, cards.count)
63 + let alice = cards[0]
64 + XCTAssertEqual("Alice V4", alice.fullName)
65 + XCTAssertEqual(["alice@acme.test"], alice.emails)
66 + XCTAssertEqual(["+33601020304"], alice.phones)
67 + }
68 +
69 + func testUnfoldFoldedLine() {
70 + // Leading space/tab of continuation is the fold marker (dropped); extra space stays in value.
71 + let folded = "BEGIN:VCARD\n"
72 + + "VERSION:3.0\n"
73 + + "FN:Alice\n"
74 + + " Martin\n"
75 + + "NOTE:hello\n"
76 + + "\t world\n"
77 + + "END:VCARD"
78 + let cards = VCardParser.parse(folded)
79 + XCTAssertEqual(1, cards.count)
80 + XCTAssertEqual("Alice Martin", cards[0].fullName)
81 + XCTAssertEqual("hello world", cards[0].note)
82 + }
83 +
84 + func testIgnorePhoto() {
85 + let text = """
86 + BEGIN:VCARD
87 + VERSION:3.0
88 + FN:Photo Person
89 + PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ
90 + EMAIL:photo@test.example
91 + END:VCARD
92 + """
93 + let cards = VCardParser.parse(text)
94 + XCTAssertEqual(1, cards.count)
95 + XCTAssertEqual("Photo Person", cards[0].fullName)
96 + XCTAssertEqual(["photo@test.example"], cards[0].emails)
97 + XCTAssertNil(cards[0].note)
98 + }
99 +
100 + func testExportRoundtripVersion30() {
101 + let cards = VCardParser.parse(loadFixture("sample3.vcf"))
102 + let vcf = VCardSerializer.toVCard3(cards[0])
103 + XCTAssertTrue(vcf.contains("VERSION:3.0"))
104 + XCTAssertTrue(vcf.contains("FN:Alice Martin"))
105 + XCTAssertTrue(vcf.contains("ORG:Acme"))
106 + }
107 +}
A ios/Card2vcfTests/VCardSerializerTest.swift
+41 -0
@@ -0,0 +1,41 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class VCardSerializerTest: XCTestCase {
5 + func testVcardMinimal() {
6 + let vcf = VCardSerializer.toVCard3(ContactCard(fullName: "Jean Dupont"))
7 + XCTAssertTrue(vcf.contains("BEGIN:VCARD"))
8 + XCTAssertTrue(vcf.contains("FN:Jean Dupont"))
9 + XCTAssertTrue(vcf.contains("END:VCARD"))
10 + }
11 +
12 + func testAccentsEtEchappement() {
13 + let vcf = VCardSerializer.toVCard3(
14 + ContactCard(
15 + fullName: "Élodie Martin",
16 + company: "Société; Générale",
17 + note: "Ligne1\nLigne2"
18 + )
19 + )
20 + XCTAssertTrue(vcf.contains("FN:Élodie Martin"))
21 + XCTAssertTrue(vcf.contains("NOTE:Ligne1\\nLigne2"))
22 + XCTAssertTrue(vcf.contains("ORG:Société\\; Générale"))
23 + }
24 +
25 + func testMultiTelEtEmail() {
26 + let vcf = VCardSerializer.toVCard3(
27 + ContactCard(
28 + fullName: "Test",
29 + phones: ["0612345678", "0145678901"],
30 + emails: ["a@b.fr", "c@d.fr"]
31 + )
32 + )
33 + let lines = vcf.components(separatedBy: "\n")
34 + XCTAssertEqual(2, lines.filter { $0.hasPrefix("TEL;") }.count)
35 + XCTAssertEqual(2, lines.filter { $0.hasPrefix("EMAIL;") }.count)
36 + }
37 +
38 + func testEscapeUnitaire() {
39 + XCTAssertEqual("a\\;b", VCardSerializer.escape("a;b"))
40 + }
41 +}
A ios/README.md
+85 -0
@@ -0,0 +1,85 @@
1 +# Card2vcf — iOS
2 +
3 +Portage **natif Swift/SwiftUI** de l'application Android (voir le
4 +[README racine](../README.md) pour la description fonctionnelle :
5 +scan de carte de visite → OCR local → brouillon éditable → carnet
6 +local → export `.vcf`, sync Projectiaon optionnelle).
7 +
8 +Comme sur Android, **l'OCR et le carnet restent 100 % sur l'appareil** :
9 +aucune dépendance tierce, aucun service cloud — uniquement les
10 +frameworks Apple embarqués. Le réseau ne sert qu'à la sync optionnelle.
11 +
12 +## Build
13 +
14 +Prérequis : Xcode 16+ (testé Xcode 26.6), [XcodeGen](https://github.com/yonaskolb/XcodeGen).
15 +
16 +```bash
17 +cd ios
18 +xcodegen generate
19 +open Card2vcf.xcodeproj # ou :
20 +xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \
21 + -destination 'platform=iOS Simulator,name=iPhone 17' build
22 +```
23 +
24 +Tests (mêmes suites que les tests JUnit Android, portées en XCTest) :
25 +
26 +```bash
27 +xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \
28 + -destination 'platform=iOS Simulator,name=iPhone 17' test
29 +```
30 +
31 +Cible : iOS 16+, iPhone et iPad. Le `.xcodeproj` est généré (non
32 +versionné) ; la vérité est `project.yml`.
33 +
34 +## Correspondances Android ↔ iOS
35 +
36 +| Fonction | Android | iOS |
37 +|---|---|---|
38 +| Capture + correction perspective | CameraX + OpenCV | VisionKit `VNDocumentCameraViewController` (import photo : `VNDetectRectanglesRequest` + `CIPerspectiveCorrection`) |
39 +| OCR (7 langues, on-device) | Tesseract4Android | Vision `VNRecognizeTextRequest` (boucle d'orientation identique) |
40 +| Structuration heuristique | `ContactHeuristicParser` (Kotlin) | port Swift 1:1 (mêmes regex) |
41 +| Carnet local + FTS | Room v3 + FTS4 | SQLite3 natif (acteur Swift) + FTS5 external-content, mêmes tables/colonnes |
42 +| Import/Export VCF | `VCardParser`/`VCardSerializer` | ports Swift 1:1 (vCard 3.0/4.0 en lecture, 3.0 en écriture) |
43 +| Contact système | `ACTION_INSERT` | `CNContactViewController` (Contacts) |
44 +| Stockage clé API chiffrée | EncryptedSharedPreferences + Argon2id/XChaCha20 | Keychain + port Swift byte-compatible (Argon2id RFC 9106 + XChaCha20-Poly1305 via CryptoKit) |
45 +| Client sync | OkHttp + kotlinx-serialization | URLSession + Codable (mêmes endpoints, mêmes JSON) |
46 +| Pont Agenda | CalendarProvider (calendriers locaux) | EventKit (calendriers `EKSource` locaux) |
47 +| UI | Jetpack Compose | SwiftUI (mêmes écrans, chaîne graphique `design.md`) |
48 +
49 +## Structure
50 +
51 +```
52 +ios/
53 +├── project.yml # définition XcodeGen (2 cibles : app + tests)
54 +├── Card2vcf/
55 +│ ├── App/ # point d'entrée SwiftUI
56 +│ ├── Contact/ # ContactCard, parseur heuristique, vCard
57 +│ ├── CRM/ # normalisation, doublons, fusion, tri, abcédaire
58 +│ ├── OCR/ # OcrResult, post-traitement, moteur Vision
59 +│ ├── Scan/ # caméra document, recadrage, pipeline scan→brouillon
60 +│ ├── Data/ # entités, protocoles DAO, dépôt, images
61 +│ │ └── Sqlite/ # implémentation SQLite3 + FTS5 (schéma Room v3)
62 +│ ├── Sync/ # client Ailiance, moteur LWW, crypto, agenda EventKit
63 +│ ├── Kanban/ # filtres kanban
64 +│ └── UI/ # écrans SwiftUI (carnet, fiche, scan, projets, réglages)
65 +└── Card2vcfTests/ # suites XCTest portées des tests JUnit
66 +```
67 +
68 +## Écarts connus vs Android
69 +
70 +- **Langues OCR** : Vision ne propose pas de correction linguistique
71 + `pol`/`nld` sur iOS 16 ; le texte latin reste reconnu (recognizer
72 + latin), sans correction spécifique à ces deux langues.
73 +- **Note du contact système** : le champ note de `CNContact` exige
74 + l'entitlement restreint `com.apple.developer.contacts.notes` — non
75 + renseigné à la création du contact système (la note reste dans le
76 + carnet Card2vcf).
77 +- **Rotation manuelle de l'image de carte** (bouton 90° Android) : non
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.
83 +- **HTTP clair** : refusé par défaut (`ServerUrlPolicy`), la case
84 + « Autoriser HTTP » reste nécessaire ; l'exception ATS globale du
85 + `Info.plist` n'est effective qu'une fois cette case cochée.
A ios/project.yml
+35 -0
@@ -0,0 +1,35 @@
1 +name: Card2vcf
2 +options:
3 + bundleIdPrefix: fr.ebii
4 + deploymentTarget:
5 + iOS: "16.0"
6 + createIntermediateGroups: true
7 +settings:
8 + base:
9 + SWIFT_VERSION: "5.9"
10 + CODE_SIGN_STYLE: Automatic
11 + ENABLE_USER_SCRIPT_SANDBOXING: "YES"
12 +targets:
13 + Card2vcf:
14 + type: application
15 + platform: iOS
16 + sources:
17 + - path: Card2vcf
18 + settings:
19 + base:
20 + PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.card2vcf
21 + INFOPLIST_FILE: Card2vcf/Info.plist
22 + TARGETED_DEVICE_FAMILY: "1,2"
23 + OTHER_LDFLAGS: -lsqlite3
24 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
25 + Card2vcfTests:
26 + type: bundle.unit-test
27 + platform: iOS
28 + sources:
29 + - path: Card2vcfTests
30 + dependencies:
31 + - target: Card2vcf
32 + settings:
33 + base:
34 + PRODUCT_BUNDLE_IDENTIFIER: fr.ebii.card2vcf.tests
35 + GENERATE_INFOPLIST_FILE: "YES"