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 | +[](LICENSE) | |
| 8 | + | |
| 9 | + | |
| 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: ¤t, 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" |
GitRust