Compare commits

..

812 Commits
compil ... main

Author SHA1 Message Date
Razmig Sarkissian 745f5884ab b2 4 days ago
Razmig Sarkissian e6aaa620fe ios 26.1 fixes 4 days ago
Razmig Sarkissian a58541b5bf add some progressview 4 days ago
Razmig Sarkissian 425451424a build 2 5 days ago
Razmig Sarkissian 431a388b13 fix an issue with search player and add format helper view in toolbox 5 days ago
Razmig Sarkissian e05dfa66b8 v1.2.62 7 days ago
Razmig Sarkissian 78f31c45d3 v61 2 weeks ago
Razmig Sarkissian d9657ace50 fix ongoing 2 weeks ago
Razmig Sarkissian a16897f3ed v1.2.60 2 weeks ago
Razmig Sarkissian 4d366b437d add a way to filter out tournament in ongoing view 2 weeks ago
Razmig Sarkissian 00d759dd6c build 4 2 weeks ago
Razmig Sarkissian 57945f6cfd build 3 2 weeks ago
Razmig Sarkissian a5445e7280 fix issue with auto structure 2 weeks ago
Razmig Sarkissian b287b67a0c build 2 2 weeks ago
Razmig Sarkissian 41fbbc3c95 v1.2.59 2 weeks ago
Razmig Sarkissian 5d49680cca Merge remote-tracking branch 'refs/remotes/origin/main' 2 weeks ago
Razmig Sarkissian ec5fc5b5e2 fix menu option 2 weeks ago
Razmig Sarkissian 769f29c41a fix menu option 3 weeks ago
Razmig Sarkissian dd54cfa9fd v1.2.58 3 weeks ago
Razmig Sarkissian aaeebd6d75 fix planning stuff 3 weeks ago
Razmig Sarkissian 9fb5ed889e fix crash in head manager 3 weeks ago
Razmig Sarkissian 7ba7012c57 v1.2.57 3 weeks ago
Razmig Sarkissian 51deb72da0 1.2.57 build 3 3 weeks ago
Razmig Sarkissian 236406e262 small improvements 3 weeks ago
Razmig Sarkissian 9bb753cce1 build 2 3 weeks ago
Razmig Sarkissian 11914c054f v1.2.57 3 weeks ago
Razmig Sarkissian 63496d334f fix send all by event 3 weeks ago
Razmig Sarkissian ceaa03c41f add a call all event method 3 weeks ago
Razmig Sarkissian 7c3801cb51 Merge remote-tracking branch 'refs/remotes/origin/main' 3 weeks ago
Razmig Sarkissian 757f22cc67 improve call team view 3 weeks ago
Laurent 4e92e23f84 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 3 weeks ago
Laurent 46af357538 add websocket infos 3 weeks ago
Razmig Sarkissian 858a68c572 add global search 3 weeks ago
Razmig Sarkissian 413e2436dd 1.2.56 4 weeks ago
Laurent f6cf835ebf Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 4 weeks ago
Laurent 1b2fb9dc0c backup now contains part of the parent folder 4 weeks ago
Razmig Sarkissian 0de19382d8 fix request payment positionning 4 weeks ago
Razmig Sarkissian ce7fce7dfd Merge remote-tracking branch 'refs/remotes/origin/main' 4 weeks ago
Razmig Sarkissian b5d5cd4aeb add payment link api 4 weeks ago
Laurent 99cf9df1ef Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 4 weeks ago
Laurent 035f8ccc9d Adds CLAUDE.md file 4 weeks ago
Razmig Sarkissian bd03321cc0 improve export data capability for teams / players 4 weeks ago
Razmig Sarkissian 18228396bf build 2 4 weeks ago
Razmig Sarkissian dbd970f87f add custom club name option in tournament for calling teams 4 weeks ago
Razmig Sarkissian 8379eccfb6 fix registion issues not displayed 4 weeks ago
Razmig Sarkissian 43f5ac97a4 add helper footer 4 weeks ago
Razmig Sarkissian a3880b04bd fix head manager match count 4 weeks ago
Razmig Sarkissian 45319790aa fix stuff 4 weeks ago
Razmig Sarkissian b41e8064d7 some fixes 4 weeks ago
Razmig Sarkissian 6c634399d7 fix stuff headmanager 4 weeks ago
Razmig Sarkissian 13011e2b1c add heads config system 4 weeks ago
Razmig Sarkissian ac18a14863 fix toolbox debug view 4 weeks ago
Razmig Sarkissian 05f132316c add global format picker 1 month ago
Razmig Sarkissian 15ae97faf5 v1.2.55 1 month ago
Razmig Sarkissian 4badce1a06 couple of fixes 1 month ago
Razmig Sarkissian ef28a98f20 add format selection to horaire and format view 1 month ago
Razmig Sarkissian 0f14852858 fix icons 1 month ago
Razmig Sarkissian cc533081ac build 2 1 month ago
Razmig Sarkissian 44f9ab1b1c fix agenda 1 month ago
Razmig Sarkissian fbd2a083b1 fix wording 1 month ago
Razmig Sarkissian e466543628 add sharing back 1 month ago
Razmig Sarkissian 8fdeff82f1 overhaul screens disposition 1 month ago
Laurent 2183f2863f Remove sharing button 1 month ago
Laurent dec6f21db9 Adds payment when adding supervisors 1 month ago
Razmig Sarkissian b239ff9a07 v1.2.54 1 month ago
Razmig Sarkissian 15e480cf28 fix main menu 1 month ago
Razmig Sarkissian b2bc59c19e Merge remote-tracking branch 'refs/remotes/origin/sync3' 1 month ago
Razmig Sarkissian 2d40e5b816 fix shared tournament umpire stuff 1 month ago
Laurent 0520ad75a5 Merge branch 'sync3' of https://gitea.staxriver.com/staxriver/PadelClub into sync3 1 month ago
Laurent cc50cc45ac draft 1 month ago
Razmig Sarkissian 09d5da914c fix title partager 1 month ago
Razmig Sarkissian c2ccbf5dd7 improve view sharing 1 month ago
Razmig Sarkissian 154137d25f Merge branch 'main' 1 month ago
Razmig Sarkissian c7d5f4930e fix match format setup 1 month ago
Razmig Sarkissian b8680eeea1 Merge branch 'main' 1 month ago
Razmig Sarkissian d1435688eb fix payment stuff 1 month ago
Laurent 08cf60629b Implement the capacity from a user to remove himself from the sharing 1 month ago
Laurent 4a07d430b7 adds code to remove tournament from being shared 1 month ago
Laurent e7b0571e9f Bumps version to 1.2.53 1 month ago
Razmig Sarkissian 4acb1e8ea4 fix ios 26 stuff 1 month ago
Razmig Sarkissian 58f61f395f fix sharing stuff 1 month ago
Razmig Sarkissian 4441554881 Merge branch 'main' 1 month ago
Razmig Sarkissian 06dd0fc3cc fix stuff 1 month ago
Laurent a0d6580a98 fix sync issues 1 month ago
Razmig Sarkissian b9a052e7d9 Merge branch 'main' 1 month ago
Razmig Sarkissian 445a180762 fix left aligne planning view 1 month ago
Razmig Sarkissian ea13b13101 Merge branch 'main' 1 month ago
Razmig Sarkissian 052204a8d6 v1.2.53 1 month ago
Razmig Sarkissian 8602881562 fix ios 26 1 month ago
Razmig Sarkissian aaa1c16660 fix merge issue 1 month ago
Razmig Sarkissian d1bbd75015 Merge remote-tracking branch 'refs/remotes/origin/sync3' 1 month ago
Laurent f95bc4de92 merge 1 month ago
Laurent 67cfa830a8 fix build 1 month ago
Razmig Sarkissian 86c1fa26cc add subtitle to menu gestion du tournoi 1 month ago
Razmig Sarkissian 74b61c6046 remove refresh team action and task 1 month ago
Razmig Sarkissian 64d0d7c307 fix issue with merge 1 month ago
Razmig Sarkissian 0a613e6376 Merge branch 'main' 1 month ago
Razmig Sarkissian 7259777ba5 fix tournament menu 1 month ago
Razmig Sarkissian dd91369c6b animation fix in debug 1 month ago
Laurent ed161df9ba merge 1 month ago
Razmig Sarkissian 3305c9aaf4 v1.2.52 2 months ago
Razmig Sarkissian 3af1de6ff9 fix issue with ios 26 2 months ago
Razmig Sarkissian cb4e2c5ed6 Merge remote-tracking branch 'refs/remotes/origin/main' 2 months ago
Razmig Sarkissian 1601f72ad2 fix issue with ios 26 2 months ago
Laurent 9e46c3234f Payment upgrade 2 months ago
Laurent 2e2b83f804 change sync server to padelclub.app 2 months ago
Razmig Sarkissian 604d24c326 v1.2.51 2 months ago
Laurent 8987c46fae Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 2 months ago
Laurent a80e2c6945 always show offer 2 months ago
Laurent dc02001505 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 2 months ago
Laurent c5ff31a396 change default storefront for IAP 2 months ago
Laurent 488e33e253 remove network icon 2 months ago
Laurent eb7df86c50 adds pack of 10 tournaments IAP 2 months ago
Laurent 2299e941b2 Consequences of Guard changes 2 months ago
Razmig Sarkissian 463a7af43b fix issue with field setup 2 months ago
Razmig Sarkissian 29636ac374 ios 26 version 2 months ago
Laurent 3875d8558b Merge branch 'sync3' of https://gitea.staxriver.com/staxriver/PadelClub into sync3 2 months ago
Laurent a9ce9659f1 add information about sync 2 months ago
Razmig Sarkissian 7b5968b1d0 fix issue with import session 2 months ago
Razmig Sarkissian 8b7202b0eb fix issue with import session 2 months ago
Razmig Sarkissian 862b7deced fix team name playerblock view 2 months ago
Razmig Sarkissian 6237502cec fix playerblock view 2 months ago
Razmig Sarkissian 6320697c7e Merge branch 'main' 2 months ago
Razmig Sarkissian b8bf7f99e8 fix editscoreview yes 2 months ago
Razmig Sarkissian dd98548a15 fix csv export birth year FFT new system 2 months ago
Laurent c191288eb7 removes payment for summons 2 months ago
Razmig Sarkissian f6076a4230 v1.2.48 2 months ago
Razmig Sarkissian 077a56b8bb v1.2.47 3 months ago
Razmig Sarkissian 6d2d77b503 fix issue with groupstage possible crash when calculating score 3 months ago
Razmig Sarkissian ed06b68405 Merge branch 'main' 3 months ago
Razmig Sarkissian 7a08bda544 possiblité de lancer l'horaire intelligent sur tous les tournois d'un evenement en une fois 3 months ago
Razmig Sarkissian aee7b85c66 ajout du min d'équipe pour homologation 3 months ago
Razmig Sarkissian 1ce74fb235 increase number of months in tenup tournament gathering 3 to 4 3 months ago
Razmig Sarkissian 26ec624f4b update 2026 rules 3 months ago
Razmig Sarkissian 908edea494 fix issue with fft search 4 months ago
Razmig Sarkissian ae58efd2f7 fix loserbracket missing from pdf 4 months ago
Razmig Sarkissian 3781aac090 add planned date display in print options 4 months ago
Laurent faa0f8ab22 version 1.2.40 for testflight 4 months ago
Razmig Sarkissian 070d221a56 Merge remote-tracking branch 'refs/remotes/origin/main' 4 months ago
Razmig Sarkissian 331103c4c0 add contact info 4 months ago
Razmig Sarkissian a47a0c26ee add contact info 4 months ago
Laurent 892db419fe Merge branch 'main' into sync3 5 months ago
Laurent 4ee338df6d merge main 5 months ago
Razmig Sarkissian 69c0163ccb v1.2.40 5 months ago
Razmig Sarkissian 87c7c074d3 fix planning stuff 5 months ago
Razmig Sarkissian 0a3606916b fix issue with camera 5 months ago
Razmig Sarkissian 340b242665 Merge remote-tracking branch 'refs/remotes/origin/main' 5 months ago
Razmig Sarkissian 9d30f20397 v1.2.38 5 months ago
Laurent c436046ea0 Adds a soft delete button for tournaments in debug 5 months ago
Laurent d35e312c3f Merge branch 'main' into sync3 5 months ago
Razmig Sarkissian a934ad54f0 v1.2.37 5 months ago
Razmig Sarkissian fdd32440c9 fix some import stuff 5 months ago
Laurent 57439e4a93 Merge branch 'main' into sync3 5 months ago
Laurent a41080685c adds sync and fix build 5 months ago
Razmig Sarkissian da31523ded fix issue with planned date 5 months ago
Razmig Sarkissian 21289513cf fix issue with groupstage start date 5 months ago
Razmig Sarkissian 6cafb9173d v1.2.34 5 months ago
Razmig Sarkissian b611ee9afd add stat view for event / tournament 5 months ago
Razmig Sarkissian e8d41853ff add a forfait button in team group stage view 5 months ago
Laurent 07eb633ce6 Merge branch 'main' into sync3 5 months ago
Laurent 8d33bc0204 cleanup view 5 months ago
Laurent c4bd58a1af Improve tournament selection 5 months ago
Laurent a28a72075e Adds restriction for shared tournaments 5 months ago
Razmig Sarkissian 6d7c6d35b2 fix update month data rank issue 5 months ago
Razmig Sarkissian 4560ebb30a fix issue with stripe account onboarding 6 months ago
Raz 381e405d47 fix seed random pick missing for more than 32 players 6 months ago
Laurent c7575d1d67 refactoring 6 months ago
Laurent 6cefe91b37 add sharing buttons 6 months ago
Raz 235edb08f2 fix crash 6 months ago
Raz ee35304bcd fix event sharing stuff 6 months ago
Raz 4ce1d5836f Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz c59e5ecf9f v1.2.30 6 months ago
Laurent 432e78f727 adds TTC for price 6 months ago
Raz 5d9c1b9cea clean up groupstage loser bracket and init 6 months ago
Raz 0dea3ae550 add event link sharing 6 months ago
Laurent df8609d1a0 Adds sharing for matches 6 months ago
Raz b84f519929 v1.2.29 6 months ago
Raz ce3c3650ce fix payment faq wording 6 months ago
Raz 03782af8c2 fix ui for validating datepicker 6 months ago
Raz 579b4fdce2 fix wildcard pick issue 6 months ago
Raz faaff120d7 add loser bracket in pdf 6 months ago
Raz 2b3f102ac3 piste au lieu de terrains 6 months ago
Raz afa8b4bdf7 import more forgotten variables fron previous playerreg into new playerreg from beach padel 6 months ago
Raz 7cd866185e fix registration import issue 6 months ago
Raz 9cb968c441 fix live scoring 6 months ago
Raz 899a1c419c fix issue with loser round view 6 months ago
Raz 266caec83b fix issue with court setup ordering in planning 6 months ago
Raz 4f98a956b1 add csv helper 6 months ago
Raz 1b4a0204c1 fix event settings stuff 6 months ago
Raz c71f253837 v1.2.25 6 months ago
Raz f814bc84c5 update jap list export update for debug 6 months ago
Raz 12142cde37 add planning feature 6 months ago
Raz ecdc46a968 fix randomu unique index not saving 6 months ago
Raz 0fe8728a8a add accountGroupStageBreakTime groupStageRotationDifference in matchscheduler and uniqueRandomIndex in team reg 6 months ago
Raz 3d29d25f63 v1.2.24 6 months ago
Raz 6ed57be995 v1.2.24 6 months ago
Raz 323a035316 b2 6 months ago
Raz 397f30095c v1.2.23 b1 6 months ago
Raz 6b9fb6ef4c add planned start date and prog setup options 6 months ago
Raz 35a2b0f9a9 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz 28c15a7ef2 b4 6 months ago
Raz 2ec612e8b2 b4 6 months ago
Laurent 5d25f21ebb merge 6 months ago
Laurent d09153b50c re add LeStorage to embedded framework for release version to work 6 months ago
Raz 1b00a5cf97 v1.2.22 b2 6 months ago
Raz 2df66dcd9e Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz cc52f6285c add the ability to get umpire data from tenup 6 months ago
Laurent ec57e976e5 fix upload issue and framework embedding 6 months ago
Laurent 4131c4bc1a Adds purchase debugging info 6 months ago
Laurent 450966eaf5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 6 months ago
Laurent 8b12d5e68d remove connection to websockets for release 6 months ago
Raz 01a9882d66 fix resting team issue 6 months ago
Raz 049e2a8402 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Laurent 8314f7681e fix build 6 months ago
Raz f2f9ec3821 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz 6b2c902fa3 setup framework link for all target 6 months ago
Laurent 210813bb78 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 6 months ago
Laurent fcbdb69270 fix payment info being displayed when not necessary 6 months ago
Laurent 661edd3534 Stop using websockets by default for now 6 months ago
Laurent 768b185c0a remove unused file 6 months ago
Raz 85b43ef9a2 v1.2.21 6 months ago
Raz 970e89b2e5 fix issue with event 6 months ago
Laurent 26f8a94b18 settings update 6 months ago
Laurent 9fa8e60f95 merge 6 months ago
Laurent 2c99a323b9 Split project with PadelClubData 6 months ago
Raz 5a79d6ed6a update delete account navigation 6 months ago
Raz f7a208cc6b 1.2.20 6 months ago
Raz ccf6b05e0d add losing group stage position selection consolante 6 months ago
Raz 1e41f51712 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz a2eed6d4eb add remaining amount in event / tournament 6 months ago
Laurent 4663f32102 fix 6 months ago
Laurent 937edb440b fix build 6 months ago
Laurent 3d9658d41b Adds purchase refresh when opening app 6 months ago
Laurent d910ca1646 Refactor purchases and payments info 6 months ago
Raz 9547d13349 v1.2.19 7 months ago
Raz 901cd4e672 small fixes 7 months ago
Raz a8a7a5ac3d enable online reg for animation 7 months ago
Raz 0cbad6ef2d fix small stuff about p500 deadlines / call subject / event url listing / copy paste licence / call access before structure done / detached await task when deleting tournament 7 months ago
Raz fae48947a6 fix messaging online payment in playerdetail view 7 months ago
Raz 2dd89faa2d 1.2.16 7 months ago
Raz 91cb8e7e94 v1.2.15 7 months ago
Raz d24576eb3e fix slow stuff 7 months ago
Raz 34b72b4d66 fix init playerregistration 7 months ago
Raz 1143f51744 add missing variables in xctests 7 months ago
Raz 339efee333 fixes 7 months ago
Raz 8eca1f3d78 add some disable option 7 months ago
Raz 430af36802 fix online payment 7 months ago
Raz c1ac3ed998 keep payment data when importing from beach padel 7 months ago
Raz a5ad5736e3 fix stuff 7 months ago
Raz 62a9e7ea78 fix date update stuff 7 months ago
Raz 7988a9585c fix online payment stuff 7 months ago
Raz dafc180b61 fix stuff 7 months ago
Raz c2595cd8ed Merge branch 'main' 7 months ago
Laurent a29c2f63f4 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 7 months ago
Laurent 953dd61813 Fix issue with v 7 months ago
Raz 25ded849b5 Merge branch 'main' 7 months ago
Raz 4054ada79c Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 54e872622f clean up registration fee messages 7 months ago
Laurent f931147a20 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 7 months ago
Laurent 5dc08de697 Test for tryPutBeforeUpdating 7 months ago
Raz ea370cec79 clean up 7 months ago
Raz 3b3d8841a6 Merge branch 'main' 7 months ago
Raz 98d923afe4 fix 1 group stage ranking bug 7 months ago
Raz dde95cae2c add format edit in matchdetail view 7 months ago
Raz 6b573e4505 add user licence update 7 months ago
Raz f2f6e88d8a fix and clean up 7 months ago
Raz b95c7acb83 add refund system 7 months ago
Raz 02969cc971 clean up stripe id verification 7 months ago
Raz 4baf30647d Merge branch 'main' 7 months ago
Raz 0a5bf21b4b Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 9d31ee90d1 add payment stuff 7 months ago
Raz 629dfe5120 wip 7 months ago
Laurent 5af4d13fdf Fixes issue with copyServerResponse 7 months ago
Raz 3ea327cad2 add stuff for tenup / umpire 7 months ago
Raz af20cd8bd3 v1.2.13 7 months ago
Raz f1b013a88c fix search view model 7 months ago
Raz f72e98df8b v1.2.12 7 months ago
Raz 902578d660 1.2.11 7 months ago
Raz deb8ef473f b1 7 months ago
Raz 2bf26a9113 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 4009569e60 fix tournament lookup 7 months ago
Raz d5434d1d8f improve some fields 7 months ago
Laurent b123b24927 in debug use local.plist setting for plan 7 months ago
Laurent 73bf331a8b Bumps build number to 3 7 months ago
Laurent b3214cca7b clean up purchase api calls again 7 months ago
Raz 38cc535c89 v1.2.9 7 months ago
Raz 5e5c768843 fix user player profile detection 7 months ago
Raz ff66c524d3 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Laurent ceda7683ea fix build 7 months ago
Raz 29b2ec0124 fix groupstage crash 7 months ago
Raz 0db09bc475 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 1da8c48cdb fix search and lic umpire view 7 months ago
Laurent c0ab6418d8 Fix crap 7 months ago
Raz ea3e76e8e2 fix search filtering 7 months ago
Laurent bee68f916f LeStorage fixes 7 months ago
Raz 92f4a37919 fix base match scheduler default value 7 months ago
Raz c428c5c5a3 fix search 7 months ago
Raz 89b0981473 fix matchscheduler 7 months ago
Raz db20896f10 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz cd92974e14 fix 1.8 loser bracket 7 months ago
Laurent 433dcb18ed Fix tournament decoding 7 months ago
Laurent 56308b0df1 fix tournament test 7 months ago
Laurent 587fe5d995 patches tournament and upload values to server 7 months ago
Laurent b364e20aaa Fix isCanceled and payment not being properly encoded 7 months ago
Raz 0a7d9f6a66 fix 1/8eme issue 7 months ago
Raz af36a6ecc4 fix tenup stuff and signature stuff 7 months ago
Raz c4c2a5a893 fix club creation error 8 months ago
Raz 31666591b2 v1.2.4 8 months ago
Raz 6bcb25a70e fix bugs 8 months ago
Raz 1f0f8ad023 build 2 8 months ago
Raz d19b1247fd add button to import from other tournaments when no teams 8 months ago
Raz 17eb909f0f v1.2.3 8 months ago
Raz f8c4c76c47 add consolante handler 8 months ago
Raz cbbf8b970b 1.2.2 b1 8 months ago
Raz 74d6f8ba3c wip done 8 months ago
Raz acb0be6ec3 Merge branch 'main' 8 months ago
Raz 081749cc4e remove unused slow umpire stat view 8 months ago
Raz 093d67f469 fix 8 months ago
Raz fc094e5603 v1.2.2 8 months ago
Raz 87b2ee638b Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz fbd730eae6 fix issue with ts setup 8 months ago
Raz ae3052f5e8 wip 8 months ago
Laurent c60ddb772a Bumps to 1.2.1 8 months ago
Laurent 5708c9a9d8 disconnect users + remove websocket connection 8 months ago
Laurent f892a05851 Improve download view 8 months ago
Laurent f77cd9d2c9 update readme 8 months ago
Raz 4a378f8737 Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz f83cb5f251 fix issue with search 8 months ago
Laurent 186a9d6e35 update readme 8 months ago
Laurent 8bf560e566 merge main 8 months ago
Raz b27cdfa1e6 fix event list team loading 8 months ago
Laurent 5f4c0640ca Fix issue, the related user becomes the creator 8 months ago
Raz 8372030d83 fix stuff for setuping heads 8 months ago
Raz ebb4d9a367 v1.1.26 8 months ago
Raz 6ff21edba8 fix pdf rules 8 months ago
Raz 377f1eced4 add special position management 8 months ago
Laurent 37c0ac62e9 merge main 8 months ago
Laurent a6c7fddf0c hide tournament sharing buttons 8 months ago
Raz 563404d92b fix tournament deletion for the online reg tournament unpaid 8 months ago
Raz 7721302377 v1.1.25 8 months ago
Laurent 1fb273e696 LeStorage refactoring 8 months ago
Laurent e9aba1bd50 Refactor hasToken with isAuthenticated 8 months ago
Raz faa9cd8182 lock team 8 months ago
Raz 0a6d7fe797 fix table reset 8 months ago
Laurent 8c5536c99b remove print 8 months ago
Raz 51395b60a4 fix wc stuff display 8 months ago
Laurent bd48e136c8 merge main 8 months ago
Laurent 3a17267c83 add infos 8 months ago
Raz 1601b757b1 1.1.23 8 months ago
Razmig Sarkissian 83370f9e73 fix issue with update ranking and mixte tournament 8 months ago
Laurent 36504eec51 Bumps testflight target version to 1.1.22 8 months ago
Laurent ef387783c1 Merge branch 'main' into sync2 8 months ago
Razmig Sarkissian 6dccef8908 v1.1.22 8 months ago
Laurent 459a90253a minor improvements #2 8 months ago
Laurent e56c61449e minor improvements 8 months ago
Laurent 006e407ee6 Fix missing dismiss after disconnect + UI improvement 8 months ago
Laurent 6334648efd Fix memory issue when going in federal players search 8 months ago
Laurent 2b856fcde4 Fix issue where data was not deleted 8 months ago
Laurent 4dc72c0aaf Fix issue where tournament deletes kept tournament file directory 8 months ago
Laurent 602755fe2a Improve API call execution and add developer mode 8 months ago
Laurent eaabafb07e Improve API list view 8 months ago
Raz 35671d44ee remove unnecessary tip 8 months ago
Raz 8704288bf1 fix init team reg 8 months ago
Laurent a16b104757 merge main 8 months ago
Raz 5da65dbe35 v1.1.21 8 months ago
Raz 0442cf32ee Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz 7d39e94aeb v1.1.20 8 months ago
Raz 8737259186 v1.1.20 8 months ago
Raz 9aed0a118e fix bugs 8 months ago
Razmig Sarkissian 772061beba v1.1.19 8 months ago
Raz 799341c19c Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz c1ed22ed32 fix loadDataFromServerIfAllowed 8 months ago
Laurent 2127f089f7 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 8 months ago
Laurent 298ad297e8 Adds refresh of purchases when opening the app 8 months ago
Razmig Sarkissian 097290962c clean up 8 months ago
Razmig Sarkissian 14993d719e v1.1.18 8 months ago
Razmig Sarkissian 4a522abed9 fix stuff 8 months ago
Raz 289056bcd0 b2 8 months ago
Raz fdfe9b92ee minor fix 8 months ago
Razmig Sarkissian f7c393fc07 fix compilation issue post merge 8 months ago
Raz 76c22c22fe Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Razmig Sarkissian f8d41f23d0 fix online reg stuff 8 months ago
Razmig Sarkissian 41f026c013 fix start date seconds setup 8 months ago
Raz 025e22b47a fix tournament cell sttuf 8 months ago
Razmig Sarkissian 217b3dadf1 v1.1.16 8 months ago
Razmig Sarkissian 1843ab58d1 fix team label and lucky loser stuff 8 months ago
Raz e029295b6a fix crash when when replacing heads 9 months ago
Raz 018e77fda7 v1.1.14 9 months ago
Raz 981e0772e5 fix build settings 9 months ago
Raz 5c1c61d518 build 2 9 months ago
Raz b3e9c80990 fix cut display stuff 9 months ago
Laurent f46890c445 merge main 9 months ago
Raz 0afde57d8c v1.1.13 9 months ago
Raz be1db1ff71 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz 119d7e53c9 fix wildcard management 9 months ago
Laurent 7c7aee398a Bumps to 1.1.12 - 2 9 months ago
Raz a3459ba57f v1.1.11 9 months ago
Raz 5e1798af2a wip setup tournament using previous tournament preferences 9 months ago
Raz bff0e29536 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz 738b79bf9c fix stuff 9 months ago
Laurent 88a3699e46 Bumps version to 1.1.10 9 months ago
Laurent aa0955917f fix thread issue 9 months ago
Laurent 6e31435840 Adds blocking system to force app download 9 months ago
Laurent 9352975bdd Fixes tests 9 months ago
Raz 4e5dc3ea12 fix search stuff 9 months ago
Raz 8a5db5921a v1.1.9 9 months ago
Laurent ac3c21c413 merge main 9 months ago
Laurent 982520683c Adds last sync date in the debug view 9 months ago
Raz 7814195756 v1.1.8 9 months ago
Raz 715c1b69e0 fix issue with remove all seeds 9 months ago
Raz 86b3af8337 Build 2 9 months ago
Laurent 74375dec50 fix crash 9 months ago
Raz 31b13125f9 fix animation glitch rowbutton view 9 months ago
Raz 892c5e29dc change free tournament message accordingly to the 3 tournaments gift 9 months ago
Raz b5e6202906 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz e5c113ddad remove unecessary july 2024 download 9 months ago
Laurent deb6be6e6f Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 9 months ago
Laurent 8acf5fed7d Set the number to free tournaments to 3 9 months ago
Raz 8efa0307f7 save id homologation for new ranks calc 9 months ago
Raz 86570858d1 v1.1.6 9 months ago
Raz c119e5a412 fix crash when filtering with no code club 9 months ago
Laurent edc00f0515 fix issue with disconnection by making TournamentStore optional 9 months ago
Laurent 5795a3b62f Fix crash 9 months ago
Laurent cdf89c33c4 Adds Drawlog api collection loader 9 months ago
Laurent a83e630d79 Adds SideStorable 9 months ago
Raz 70355f47de fix delete tournament issue with online reg 9 months ago
Raz 2813fcf1de v1.1.4 10 months ago
Laurent 18a0ed6d10 update readme for cascading deletes in admin 10 months ago
Laurent 4e62899b30 Fix crash when deleting 10 months ago
Raz d4e193d60b fix crash in inscription info view 10 months ago
Raz 2c4781de9d fix delete team 10 months ago
Raz b4f66d9615 v bump 10 months ago
Raz ee86d08159 fix tournament deletion 10 months ago
Raz a7e4072cc9 version bump 10 months ago
Raz ca6b56f2f9 enable realtime final ranking update 10 months ago
Raz 2ebfb79713 forcer le score du 2eme à 0 si on valide sans finir de cliquer sur le num pad (edit score) 10 months ago
Raz 4893f8bf27 v1.1.0 b2 10 months ago
Raz 2447930e60 fix player import stuff 10 months ago
Raz 993f9193da v1.1.0 10 months ago
Raz 1bbd981097 Merge branch 'main' 10 months ago
Raz 15870383e0 fix stuff 10 months ago
Laurent 37660a1f12 Fix issues 10 months ago
Laurent 06662092eb fix test 10 months ago
Laurent 68a99a495b change data test user 10 months ago
Laurent 3ef38fdaa2 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 10 months ago
Laurent f2ce6c983a fix purchase update 10 months ago
Raz d11af504b5 fixes and add legend / inscription guide 10 months ago
Raz 457900f64a wip 10 months ago
Raz 945f9ddf29 wip 10 months ago
Raz b92e5cec63 Merge branch 'main' 10 months ago
Raz 3a5af393a6 fix auto publish rankings 10 months ago
Raz ecf89f6b1d fix group stage scheduling 10 months ago
Raz 51fbc26c12 fix crash in inscription info issue view 10 months ago
Raz 9a674de3cf fix new urls 10 months ago
Raz e4825b88e6 1.0.43 10 months ago
Raz 56852e75e7 merge 10 months ago
Raz 344a1f8747 Merge branch 'main' 10 months ago
Raz 96bbe1fc32 fix fft jan 2025 10 months ago
Raz 9e2674e210 fix issue with locationbutton 10 months ago
Laurent 9271cee545 Merge branch 'main' into sync 10 months ago
Laurent 780d93dfe2 update club to use the new copy system to receive its broadcast code 10 months ago
Laurent bdea94f472 cleanup 10 months ago
Raz 783e66e754 fix unnecessary save in edit player 10 months ago
Raz e1ffa4830a fix 18.2 bug 10 months ago
Raz c8fc23164c Merge branch 'main' 10 months ago
Raz 1d8ccc97d3 fix localisation string 11 months ago
Raz fd67586ea0 fix ios 18.2 bug 11 months ago
Raz 0a1385cf13 fix ios 18.2 crash 11 months ago
Raz f5f2817292 Merge branch 'main' 11 months ago
Raz 1f21cb5b05 remove the workaround on playlist 11 months ago
Raz e5c4ea0837 fix stuff 11 months ago
Raz b4e267f695 fix online reg 11 months ago
Raz cb68d10fb3 add the ability to move time slots 11 months ago
Raz 494f083a34 remove the need to have a target team count 11 months ago
Raz dc3d193b55 addcorner radius 11 months ago
Raz 242acffe22 add tournament category settings 11 months ago
Raz 906172d9e2 quick enhance tip 11 months ago
Raz 7b06c8cefc fix registration online 11 months ago
Raz 768902e827 remove unregistered var 11 months ago
Laurent 7a0d2a8da7 Improve code 11 months ago
Laurent ce6e2281e3 sets acronym when empty 11 months ago
Laurent 1210a784d0 update readme guide 11 months ago
Raz 0ba2f9d76d v1.0.40 11 months ago
Raz b73d4e49e1 short category displayed as wide, no more one letter 11 months ago
Laurent d74c6abcfa merge main 11 months ago
Raz 75a721179f v1.0.39 11 months ago
Raz 107eabf8ff fix unecessary count check of month player data 11 months ago
Raz d5a1449b3f fix sharelink lag 11 months ago
Raz afe7aedb29 fix ranking 11 months ago
Raz 15b5ba97f9 fix senior 11 months ago
Raz 3c0fa45153 fix ranking 11 months ago
Laurent 8851315cf4 Adds patch to add storeId to needing entities 11 months ago
Raz a356d9768c fix reg stuff 11 months ago
Raz 78624bd422 remove hscroll online reg 11 months ago
Raz 07a0b632f9 improve sentences 11 months ago
Laurent ce4f0d11d7 Fixes and improvements 11 months ago
Raz 2cf955f378 v1.0.38 11 months ago
Raz 607a5e65bd v1.0.37 11 months ago
Raz fc3708fbc3 fix mobile number detection 11 months ago
Laurent dfaa18d9e6 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 11 months ago
Laurent 033901d97c Fix issue 11 months ago
Raz f6cf2c45b3 v1.0.35 11 months ago
Laurent 3991bb8650 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 11 months ago
Laurent c27a524fc5 Adds patch to reset logs 11 months ago
Laurent 9ebdffddd7 cleanup generation and add tournament sharing 11 months ago
Laurent 62fd9c5610 Adds a way to share a tournament with others 11 months ago
Laurent eb1f69ec97 Improves data generation 11 months ago
Raz 2c1b00c4cf Merge branch 'main' 11 months ago
Raz 11acae92b0 fix animation settings 11 months ago
Raz 83e61f375a Merge branch 'main' 11 months ago
Raz 0ff0d3fd81 fix animation settings 11 months ago
Raz a0a6200dc9 v1.0.34 11 months ago
Raz 5f0eaa156b wip online reg 11 months ago
Raz 9a8f9f8949 improve online registration 12 months ago
Raz 420b5e9bc2 Merge branch 'tiebreak' 12 months ago
Raz 2381031452 tiebreak value wip 12 months ago
Raz acacfe1de2 fix stuff 12 months ago
Raz 7a4628208c v1.0.33 12 months ago
Laurent 786ad5ee45 Regeneration of classes to handle relationships 12 months ago
Raz 28e94cb348 online reg feature 12 months ago
Laurent 3feea9b22f Change to server url configuration 12 months ago
Raz 58ffb56ef5 add ranking to register message 12 months ago
Raz 928b3510e2 add simple contact option in call/recall menu 12 months ago
Raz e8665a3e8b add a way to know if the team is from beach 12 months ago
Raz c5bc2fb0d5 add set of four games 12 months ago
Raz 16de4ee012 optimize running matches gathering 12 months ago
Raz e852999739 clean matchviewstyle to make it environment variable 12 months ago
Raz 17e0c85e0b fix next matches display 1 year ago
Raz 41b62a73ae update matches 1 year ago
Laurent c85dbad3ca Fix issues 1 year ago
Laurent 4b2dbf9c46 Adds TournamentLibrary to manage TournamentStore, that do not inherit Store from now on 1 year ago
Laurent fee94a2b7d add storeId for MatchScheduler 1 year ago
Raz 87e4adf270 fix stuff 1 year ago
Raz 3d00c58eb2 update 1 year ago
Laurent f1b351a13f use generated class 1 year ago
Raz 41e356bf28 v1.0.30 1 year ago
Raz f60daee81f add rotation knowledge 1 year ago
Raz 1fb4b7294e v1.0.30 1 year ago
Raz 42a7487c63 fix issues 1 year ago
Raz 0e5988b4ed fix groupstage sorting 1 year ago
Raz d6e87daa3d overhault ongoing view 1 year ago
Raz 969fa5094f filter tournaments by deleted false 1 year ago
Raz 877bc33ea9 add penalty check views 1 year ago
Raz e12dbff90d fix issue with smart planning save 1 year ago
Raz 884f1d9186 add multi waves of group stage matchs management 1 year ago
Raz 619702bcff add new variable to drawlog and fix filterByStoreIdentifier 1 year ago
Raz 0c0b492d8f add locale currency 1 year ago
Raz b6e8144bfd fix issue with women in men tournament 1 year ago
Raz 1f77e287e0 prod test 1 year ago
Raz e25bcb47b4 wording debug option 1 year ago
Raz 407d73a765 wording fix 1 year ago
Raz ef20c838c4 fix wording 1 year ago
Raz dc9e735711 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz a5e09d2c06 fix bugs with smart planning 1 year ago
Raz 835bf8fd3f fix bugs with smart planning 1 year ago
Laurent d6bd8842c3 minor update 1 year ago
Raz 31f093e60e v1.0.25 b2 1 year ago
Raz 9a47f8c87d fix teams sorting in edge case scenario 1 year ago
Raz af1ea610b4 minor fixes 1 year ago
Raz 566c5c1eac enhance score input to handle first team to input score switching 1 year ago
Raz c016e5e0c8 fix game difference calculation for format b / c 1 year ago
Raz 4143236154 enhance and clean format selection 1 year ago
Laurent 0dd3dc16bd create generator to make base Swift classes 1 year ago
Raz eb451f6655 Merge branch 'match-retour' 1 year ago
Raz b7f2b33816 fix issue with match deletion in groupstage 1 year ago
Raz dced9bb0be fix 1 year ago
Raz 2195bb6037 b2 1 year ago
Raz 8fbafd8c08 wip 1 year ago
Laurent 28b2d79d2a Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 1 year ago
Laurent 52c8162a2d Adds timezone for clubs 1 year ago
Raz 883fdaea18 wip 1 year ago
Raz ea807778fe fix draw labeling 1 year ago
Raz 978f90dfcb fix private settings in debug 1 year ago
Raz b219fef6c4 v1.0.24 b1 1 year ago
Raz ee8a297f13 Merge branch 'drawlog' 1 year ago
Raz 5496bf2e96 improve unavailibity view 1 year ago
Raz 77b3f27685 draw log final implementation 1 year ago
Raz 82e1f6a342 fix regression seeding position 3/4 1 year ago
Raz 9291d63d05 drawlog wip 1 year ago
Raz 8eadd30759 add maintenance tenup message 1 year ago
Raz 585cb9cf9f fix follow up matches view picker size 1 year ago
Raz 021708c02f fix player search in cashier 1 year ago
Raz d4fedd862b add resting time 1 year ago
Raz b5041db0db fix issues 1 year ago
Raz 7b2989d29b wip 1 year ago
Raz 91c4c5b591 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 86f95c319a force always compact size class 1 year ago
Raz e72bc97bd7 force always compact size class 1 year ago
Raz a51adc6bbe add QoL features on summoning screen 1 year ago
Laurent 671edd3412 first commit 1 year ago
Raz 20eeea99b8 v1.0.23 1 year ago
Raz af1c53f1b8 fix table structure check when no group stage 1 year ago
Raz 6791aed10a fix stuff 1 year ago
Raz 87758354c6 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 87b46d7ccb fix issue with team selection 1 year ago
Laurent 7811806925 Make server purchase active 1 year ago
Raz f174ccfe57 fix calling views 1 year ago
Raz 7c3725ce5b fix stuff 1 year ago
Raz 17f371e450 fix calling stuff and round lag efficiency 1 year ago
Raz 138551c32a fix bracket calling view update 1 year ago
Raz 93036ecdd5 fix scheduler 1 year ago
Raz 47198e9b88 add a bracket calling view 1 year ago
Raz 1d70070a68 fix final ranking in groupstages when groupStageAdditionalQualified is used 1 year ago
Raz 50a0f8a6ae fix single group stage final ranking stuff 1 year ago
Raz fe5ba820e4 fix stuff 1 year ago
Raz df0a0b5e56 b2 1 year ago
Raz 673e405c2d fix add team incomplete players 1 year ago
Raz 91a3ec1700 fix some stuff with match validation and planning match limit view 1 year ago
Raz 5d2a52dce7 fix stuff 1 year ago
Raz a7af5c6b1c fix validate match saving 1 year ago
Raz e1d9b15072 1.0.21 b2 1 year ago
Raz 34ad82f177 fix stuff scheduler 1 year ago
Raz 996220fe6f fix issues with match readiness 1 year ago
Raz ad5b2f06c4 fix round title issues 1 year ago
Raz 131ee06fd4 fix registration issues lag 1 year ago
Raz 9b0bb5efe5 update version 1 year ago
Raz ff5da277db Indiquer le prochain match de chaque paire à la saisie du résultat (à chaque fin de match, le joueur me demande à quelle heure et sur quel court le prochain) 1 year ago
Raz ac60d64acc add match followup picker 1 year ago
Raz c025559b5e fix scheduler and add court pickup 1 year ago
Raz b3144be82d phrasing 1 year ago
Raz a68ffa20c5 fix scheduler when rotation start date is too early no matter what, so delay the startdate to a new computed startdate 1 year ago
Raz 8cf59a31c8 fix textfield stuff in player edition 1 year ago
Raz 176df45214 fix preset management when no group stage 1 year ago
Raz 412df66c1e v1.0.20 b1 1 year ago
Raz bfb5d78b04 add federal table structure selection 1 year ago
Raz c21d4688e3 fix seed removing not deleting team score 1 year ago
Raz 31ee3cef62 fix senterror popup 1 year ago
Raz 5f35af7fb2 fix planning by court contentunavailable 1 year ago
Raz 4c5459ed6a v1.0.19 1 year ago
Raz bfcd3a1d6b Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz b3f07a18cc add fft rules about p500+ 1 year ago
Laurent dbaf775003 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 1 year ago
Laurent ea4772e3d4 Fix Purchase decoding 1 year ago
Raz b823a10bf5 b3 1 year ago
Raz 276ec87bc7 fix datepicker with label ios18 glitches 1 year ago
Raz f528e0aacd build 2 1 year ago
Raz 91426c9990 add option to import 2 by 2 in custom import 1 year ago
Raz ca1a3a87f4 replace encodeOptional into encode 1 year ago
Raz a9735c1c21 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz d77779835d v1.0.18 1 year ago
Raz 44f4ec028b fix unecessary init refresh 1 year ago
Laurent b87c9003c8 merge 1 year ago
Laurent aa301c9e04 cleanup encode methods 1 year ago
Raz 0719d1a85d fix linelimit in player label 1 year ago
Raz 9419b60507 fix team deletion dismiss 1 year ago
Raz c089e05c1a fix courtIndex not save 1 year ago
Raz be4ee15485 fix line limit 1 year ago
Raz 74ee3a4525 v1.0.17 b1 1 year ago
Raz 8d67d7efab fix refresh round title 1 year ago
Raz d5ea4f5336 fix wildcard bracket compute bug 1 year ago
Raz c9b25227d7 fix senior+ import 1 year ago
Raz ea671ae14d remove a debug option 1 year ago
Raz a01dfcea59 fix bugs double groupstage mode 1 year ago
Raz 8dec31dd14 add toolbarBackground in player search list 1 year ago
Raz 6e68226ac7 fix some stuff on team selection and add a special animation mode for selecting players from club 1 year ago
Raz eca125ef73 fix little glitches 1 year ago
Raz 5ec65c88d5 v1.0.16 b1 1 year ago
Raz 6f929c44cf fix search stuff 1 year ago
Raz b2f38febc8 fix searching players issues 1 year ago
Raz 13be596b26 add new feature : double group stages and loser bracket smart auto generation 1 year ago
Raz 6ac26eb1e4 Merge branch 'main' 1 year ago
Raz 0e8cd58f74 add loser bracket groupstage to matchscheduler 1 year ago
Raz a0477d4fa3 v1.0.15 1 year ago
Raz dd4501b95c fix issue with matchscheduler 1 year ago
Raz d3ed0147be release 1 year ago
Raz 3638aae3c3 fix issue when importing female in male tournament 1 year ago
Raz 883a46baea fix planning views and match scheduler for groupstages 1 year ago
Raz f691681e94 fix a lot of stuff post PBL 1 year ago
Raz 8ca2193579 version 1 year ago
Raz 46032d767b fix finishing tournament when only groupstages 1 year ago
Raz b335aebcae fix stuff 1 year ago
Raz 886ba02498 wip 1 year ago
Raz 93e529c993 Merge branch 'main' 1 year ago
Raz f59f0d4aaf fix issue 1 year ago
Raz 940f9ed146 wip 1 year ago
Raz 290aad40a4 Merge branch 'main' 1 year ago
Raz f764e91384 versions 1 year ago
Raz a3f76e4a77 fix stuff 1 year ago
Raz 4d625cb19a version 1 year ago
Raz 0a816b20e1 minor updates 1 year ago
Raz 913d3de4f5 fix textfield cancel / validate UI stuff 1 year ago
Raz daec637301 v1.0.13 b4 1 year ago
Raz 0aed24a003 appstore release 1 year ago
Raz f64f7387b5 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 62e2b59875 fix some stuff 1 year ago
Raz 32d81a01e8 fix minor stuff 1 year ago
Raz af9f9a1747 fix animation / pbl stuff 1 year ago
Raz 4f3aec3e19 v1.0.13 b3 1 year ago
Raz 4c4e472c09 gs step wip 1 year ago
Laurent 0545b621b9 Remove logs 1 year ago
Raz 0352fffc4d v1.0.12 1 year ago
Raz 3b62bd8a79 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 051065b453 fix search player stuff 1 year ago
Laurent 7f3c190e3f Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 1 year ago
Laurent 3106ad2175 Fixes issue where stuff remained after disconnect 1 year ago
Raz 03b3e13a36 v1.0.12 1 year ago
Raz 9eecd8f624 fix category search 1 year ago
Raz c37fcb1373 v1.0.11 1 year ago
Raz d422684df0 add a way to show which team not contacted 1 year ago
Raz fe737d2493 fix no contact data 1 year ago
Raz 42e1598fb4 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 493b595d5f fix contact missing 1 year ago
Laurent be89a1bda0 now accepts purchase without appAccountToken 1 year ago
Laurent e950669132 Improve fi 1 year ago
Raz 6dc3a35628 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 7f2a3dfc0a fix unit test 1 year ago
Raz bb76b3a1fa add missing encoding 1 year ago
Laurent 447f57067d Fix crash when deleting tournament 1 year ago
Raz 92161a1ee4 1.0.10 1 year ago
Raz e4e909df3e Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 77d722db50 fix ios 18 team name edition 1 year ago
Laurent 32577b810b merge 1 year ago
Laurent cf3cb3ec18 Fix issue with Purchase, add fields to Purchase + convenience methods to encode/decode + fix tests 1 year ago
Raz 3a7409ef0f 1.0.10 for testflight prod 1 year ago
Raz ec9518e41f v1.0.9 b3 1 year ago
Raz 6391ace9e0 little fixes 1 year ago
Raz 0161203e84 Merge branch 'retrieve' 1 year ago
Raz acda8c2530 add scheduler toast when delete startdates 1 year ago
Raz 0593ceb58d Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 19b413ff3e add loser bracket mode 1 year ago
Raz 393069ec45 fix stuff in file import 1 year ago
Raz 09c4012990 fix search stuff 1 year ago
Laurent b74c7a3c74 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 1 year ago
Raz d059002342 Merge branch 'wip_main' 1 year ago
Raz ef91eb6f04 gp class 1 year ago
Raz 10c86dd15c Merge branch 'main' 1 year ago
Raz da0ec5b231 gp stage fix 1 year ago
Raz e7f2ae8ee1 1.0.9 testflight build 1 year ago
Raz cce0f095c2 1.0.9 prod test first release 1 year ago
Raz f856463662 prod test target 1 year ago
Raz 2f14a16852 wip 1 year ago
Laurent a6177c22ac Fix issue with disconnect 1 year ago
Raz 640de8a8d0 build 5 1 year ago
Raz d8d43b7112 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 5f01323616 fix search player by licence regression 1 year ago
Laurent cc427dbdea merge 1 year ago
Laurent 24e79b1eb8 Fix issue where tournaments still appears after a disconnect 1 year ago
Raz 3eea8d2976 fix search stuff 1 year ago
Raz 0a76d3c9e3 Merge remote-tracking branch 'refs/remotes/origin/main' 1 year ago
Raz 90aee596f1 fix player search 1 year ago
Laurent 19c548598f Adds promotional transactions to valid transactions 1 year ago
Raz badd580de7 update de la vue publication 1 year ago
Laurent 1dba6633ee put back debug plans 1 year ago
Laurent edc99d1e79 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 1 year ago
Laurent 9c2b93ad62 Fix an issue where payed tournament units did not show up 1 year ago
Raz 5469ce290c fix issues 1 year ago
Raz d4bae677d4 fix stuff 1 year ago
Raz 556f57004d fixes 1 year ago
Razmig Sarkissian 09d7852c87 fix issue 1 year ago
Razmig Sarkissian 89a70fbbba change bundle id for testflight 1 year ago
Razmig Sarkissian 07460ba67a fix plist issue 1 year ago
Razmig Sarkissian 550aa91c56 change display name for testflight target 1 year ago
Razmig Sarkissian 26836e0dd7 1.0.8 1 year ago
Razmig Sarkissian b8262de8bf fix merge 1 year ago
Razmig Sarkissian 3cdc8e945e fix merge 1 year ago
Razmig Sarkissian 7b1a5cd37f fix merge 1 year ago
Razmig Sarkissian 3e503bfcd1 add a testflight target 1 year ago
Razmig Sarkissian fdfaebe44c Merge branch 'main' 1 year ago
Razmig Sarkissian 68338f580a clean up new features 1 year ago
Razmig Sarkissian 470aa619d6 add tournament subscribe 1 year ago
Razmig Sarkissian 6b354884d8 1.0.7 1 year ago
Razmig Sarkissian 933cf13df7 Merge branch 'main' 1 year ago
Razmig Sarkissian 63f5b509d2 b3 1 year ago
Razmig Sarkissian 8fc6f09908 clean up 1 year ago
Laurent 7d4ccf0f67 adds a way to see product ids in the interface 1 year ago
Laurent c9b812cd7e Change Guard init location 1 year ago
Raz 8e301a4f24 update tournament search feature 1 year ago
Raz 708e0aa481 wip around me tab 1 year ago
Raz 40fc73e6bf fix new feature loser bracket groupstages 1 year ago
Raz 9a2d293b41 wip 1 year ago
Raz ae0fb47c74 Merge branch 'main' 1 year ago
Raz 85f437dc08 build 2 1 year ago
Raz 289a4fa99d Merge branch 'main' 1 year ago
Raz 486286db83 phrasing 1 year ago
Raz a442cd934d phrasing 1 year ago
Raz bf1f934704 Merge branch 'main' 1 year ago
Raz d4a40a20b6 clean up 1 year ago
Raz c8cd6c1af1 Merge branch 'main' 1 year ago
Raz ac641cfca0 fix debug _disablePrivateToggle 1 year ago
Raz e318df1eb5 Merge branch 'main' 1 year ago
Raz 29dc0bf8bf v1.0.6 1 year ago
Raz eec308bb8b fix debug 1 year ago
Raz 888672cc9b v1.0.6 1 year ago
Laurent f03621124a Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 1 year ago
Laurent 48a5dd4cc3 Adds patch for missing matches 1 year ago
Raz 143a17f5b4 fix debug 1 year ago
Raz 0860fb5bca v1.0.6 1 year ago
Raz ba87b39c76 Merge branch 'main' 1 year ago
Raz ff0b483369 fix regression 1 year ago
Raz e1a504f63f fix regression 1 year ago
Raz 36213d2713 Merge branch 'main' 1 year ago
Razmig Sarkissian 7c3321b3b1 b4 1 year ago
Laurent 32447ef056 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 1 year ago
Laurent 6238e603c9 fix issue with debug/release url 1 year ago
Razmig Sarkissian a1f53ab8fb xcode 16 1 year ago
Razmig Sarkissian fbca6c3e21 b3 1 year ago
Razmig Sarkissian 5c5c741fba fix release branch 1 year ago
Razmig Sarkissian 37e184e199 fix issue with player look up with copy paste / predicate 1 year ago
Razmig Sarkissian b15497f303 b2 beta 1 year ago
Razmig Sarkissian 47a11aca64 fix crash 1 year ago
Razmig Sarkissian 60eb840604 Merge branch 'main' 1 year ago
Razmig Sarkissian 71822ac204 implement custom xls to csv option 1 year ago
Raz e800b5b4ff build 5 1 year ago
Raz f64a600f16 fix links 1 year ago
Raz 48c865ae33 Merge branch 'main' 1 year ago
Raz 17c4911a89 fix stuff 1 year ago
Raz de19ce4384 Merge branch 'main' 1 year ago
Raz 3d707fea8c little fixes 1 year ago
Raz 54b18e264f Merge branch 'main' 1 year ago
Raz 1dd2b1ee5d fix stuff 1 year ago
Raz 6bf9bdf634 add prepoluted seed 1 year ago
Raz 057efee144 clubs update with reset 1 year ago
  1. 8
      CLAUDE.md
  2. 2013
      PadelClub.xcodeproj/project.pbxproj
  3. 48
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub ProdTest.xcscheme
  4. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  5. 78
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  6. 4
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  7. 2
      PadelClub.xcworkspace/contents.xcworkspacedata
  8. 59
      PadelClub/AppDelegate.swift
  9. 33
      PadelClub/Assets.xcassets/beigeNotUniversal.colorset/Contents.json
  10. 33
      PadelClub/Assets.xcassets/grayNotUniversal.colorset/Contents.json
  11. 6
      PadelClub/Assets.xcassets/logoRed.colorset/Contents.json
  12. 6
      PadelClub/Assets.xcassets/logoYellow.colorset/Contents.json
  13. 86
      PadelClub/Data/Club+Extensions.swift
  14. 38
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  15. 4
      PadelClub/Data/Coredata/PadelClubApp.xcdatamodeld/.xccurrentversion
  16. 30
      PadelClub/Data/Coredata/PadelClubApp.xcdatamodeld/Model_1_1.xcdatamodel/contents
  17. 47
      PadelClub/Data/Coredata/Persistence.swift
  18. 73
      PadelClub/Data/Enum+Extensions.swift
  19. 77
      PadelClub/Data/Federal/FederalPlayer.swift
  20. 289
      PadelClub/Data/Federal/FederalTournament.swift
  21. 8
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  22. 16
      PadelClub/Data/Federal/PlayerHolder.swift
  23. 55
      PadelClub/Data/GroupStage+Extensions.swift
  24. 89
      PadelClub/Data/Match+Extensions.swift
  25. 13
      PadelClub/Data/MatchScheduler+Extensions.swift
  26. 230
      PadelClub/Data/PlayerRegistration+Extensions.swift
  27. 21
      PadelClub/Data/README.md
  28. 155
      PadelClub/Data/Round+Extensions.swift
  29. 20
      PadelClub/Data/SeedInterval+Extensions.swift
  30. 109
      PadelClub/Data/TeamRegistration+Extensions.swift
  31. 395
      PadelClub/Data/Tournament+Extensions.swift
  32. 2704
      PadelClub/Data/Tournament.swift
  33. 38
      PadelClub/Extensions/Array+Extensions.swift
  34. 25
      PadelClub/Extensions/Badge+Extensions.swift
  35. 26
      PadelClub/Extensions/Calendar+Extensions.swift
  36. 9
      PadelClub/Extensions/CustomUser+Extensions.swift
  37. 180
      PadelClub/Extensions/Date+Extensions.swift
  38. 25
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  39. 23
      PadelClub/Extensions/Locale+Extensions.swift
  40. 26
      PadelClub/Extensions/MonthData+Extensions.swift
  41. 16
      PadelClub/Extensions/NumberFormatter+Extensions.swift
  42. 230
      PadelClub/Extensions/PlayerRegistration+Extensions.swift
  43. 33
      PadelClub/Extensions/Round+Extensions.swift
  44. 33
      PadelClub/Extensions/Sequence+Extensions.swift
  45. 34
      PadelClub/Extensions/SourceFileManager+Extensions.swift
  46. 42
      PadelClub/Extensions/SpinDrawable+Extensions.swift
  47. 151
      PadelClub/Extensions/String+Extensions.swift
  48. 84
      PadelClub/Extensions/TeamRegistration+Extensions.swift
  49. 428
      PadelClub/Extensions/Tournament+Extensions.swift
  50. 22
      PadelClub/Extensions/View+Extensions.swift
  51. 5
      PadelClub/HTML Templates/bracket-template.html
  52. 1
      PadelClub/HTML Templates/groupstage-template.html
  53. 12
      PadelClub/HTML Templates/match-template.html
  54. 1
      PadelClub/HTML Templates/player-template.html
  55. 36
      PadelClub/HTML Templates/tournament-template.html
  56. 2
      PadelClub/Info.plist
  57. 60
      PadelClub/OnlineRegistrationWarningView.swift
  58. 132
      PadelClub/PadelClubApp.swift
  59. BIN
      PadelClub/SeedData/local.sqlite
  60. 69
      PadelClub/SyncedProducts.storekit
  61. 224
      PadelClub/Utils/CloudConvert.swift
  62. 205
      PadelClub/Utils/ContactManager.swift
  63. 23
      PadelClub/Utils/DisplayContext.swift
  64. 37
      PadelClub/Utils/ExportFormat.swift
  65. 125
      PadelClub/Utils/FileImportManager.swift
  66. 25
      PadelClub/Utils/HtmlGenerator.swift
  67. 160
      PadelClub/Utils/HtmlService.swift
  68. 17
      PadelClub/Utils/LocationManager.swift
  69. 90
      PadelClub/Utils/Network/ConfigurationService.swift
  70. 302
      PadelClub/Utils/Network/FederalDataService.swift
  71. 136
      PadelClub/Utils/Network/NetworkFederalService.swift
  72. 5
      PadelClub/Utils/Network/NetworkManager.swift
  73. 28
      PadelClub/Utils/Network/NetworkManagerError.swift
  74. 77
      PadelClub/Utils/Network/PaymentService.swift
  75. 50
      PadelClub/Utils/Network/RefundService.swift
  76. 207
      PadelClub/Utils/Network/StripeValidationService.swift
  77. 83
      PadelClub/Utils/Network/XlsToCsvService.swift
  78. 59
      PadelClub/Utils/PhoneNumbersUtils.swift
  79. 257
      PadelClub/Utils/SourceFileManager.swift
  80. 70
      PadelClub/Utils/SwiftParser.swift
  81. 128
      PadelClub/Utils/Tips.swift
  82. 80
      PadelClub/Utils/URLs.swift
  83. 33
      PadelClub/Utils/VersionComparator.swift
  84. 59
      PadelClub/ViewModel/AgendaDestination.swift
  85. 140
      PadelClub/ViewModel/FederalDataViewModel.swift
  86. 101
      PadelClub/ViewModel/MatchDescriptor.swift
  87. 51
      PadelClub/ViewModel/MatchViewStyle.swift
  88. 3
      PadelClub/ViewModel/NavigationViewModel.swift
  89. 616
      PadelClub/ViewModel/SearchViewModel.swift
  90. 51
      PadelClub/ViewModel/Selectable.swift
  91. 33
      PadelClub/ViewModel/SetDescriptor.swift
  92. 5
      PadelClub/ViewModel/TabDestination.swift
  93. 215
      PadelClub/Views/Calling/BracketCallingView.swift
  94. 48
      PadelClub/Views/Calling/CallMessageCustomizationView.swift
  95. 13
      PadelClub/Views/Calling/CallSettingsView.swift
  96. 321
      PadelClub/Views/Calling/CallView.swift
  97. 53
      PadelClub/Views/Calling/Components/MenuWarningView.swift
  98. 8
      PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift
  99. 39
      PadelClub/Views/Calling/GroupStageCallingView.swift
  100. 44
      PadelClub/Views/Calling/SeedsCallingView.swift
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,8 @@
## Padel Club
This is the main directory of a Swift app that helps padel tournament organizers.
The project is structured around three projects linked in the PadelClub.xcworkspace:
- PadelClub: this one, which mostly contains the UI for the project
- PadelClubData: the business logic for the app
- LeStorage: a local storage with a synchronization layer

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -15,10 +15,10 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C423305B2C7E118B001DABF5"
BuildableName = "PadelClubData.framework"
BlueprintName = "PadelClubData"
ReferencedContainer = "container:PadelClubData.xcodeproj">
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@ -29,22 +29,9 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C42330652C7E118B001DABF5"
BuildableName = "PadelClubDataTests.xctest"
BlueprintName = "PadelClubDataTests"
ReferencedContainer = "container:PadelClubData.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
@ -53,6 +40,16 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@ -60,15 +57,16 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C423305B2C7E118B001DABF5"
BuildableName = "PadelClubData.framework"
BlueprintName = "PadelClubData"
ReferencedContainer = "container:PadelClubData.xcodeproj">
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</MacroExpansion>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -74,7 +74,7 @@
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../PadelClub/SyncedProducts.storekit">
identifier = "../PadelClub/SyncedProducts.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction

@ -5,7 +5,7 @@
location = "group:../LeStorage/LeStorage.xcodeproj">
</FileRef>
<FileRef
location = "group:PadelClubData/PadelClubData.xcodeproj">
location = "container:../PadelClubData/PadelClubData.xcodeproj">
</FileRef>
<FileRef
location = "group:PadelClub.xcodeproj">

@ -9,21 +9,76 @@ import Foundation
import UIKit
import LeStorage
import UserNotifications
import PadelClubData
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
_ = Guard.main // init guard
self._configureLeStorage()
UIApplication.shared.registerForRemoteNotifications()
UNUserNotificationCenter.current().delegate = self
Logger.log("didFinishLaunchingWithOptions")
return true
}
fileprivate func _domain() -> String {
#if DEBUG
return "xlr.alwaysdata.net"
#elseif TESTFLIGHT
return "padelclub.app"
#elseif PRODTEST
return "padelclub.app"
#else
return "padelclub.app"
#endif
}
fileprivate func _configureLeStorage() {
StoreCenter.main.blackListUserName("apple-test")
StoreCenter.main.classProject = "PadelClubData"
// let secureScheme = true
let domain: String = self._domain()
#if DEBUG
if let secure = PListReader.readBool(plist: "local", key: "secure_server"),
let domain = PListReader.readString(plist: "local", key: "server_domain") {
StoreCenter.main.configureURLs(secureScheme: secure, domain: domain, webSockets: true, useSynchronization: true)
} else {
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
}
#else
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
#endif
StoreCenter.main.logsFailedAPICalls()
var synchronized: Bool = true
#if DEBUG
if let sync = PListReader.readBool(plist: "local", key: "synchronized") {
synchronized = sync
}
#endif
StoreCenter.main.forceNoSynchronization = !synchronized
}
func applicationWillEnterForeground(_ application: UIApplication) {
Task {
await Guard.main.refreshPurchases()
}
}
// MARK: - Remote Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if StoreCenter.main.hasToken() {
if StoreCenter.main.isAuthenticated {
Task {
do {
let services = try StoreCenter.main.service()

@ -0,0 +1,33 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.808",
"green" : "0.906",
"red" : "0.980"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"platform" : "ios",
"reference" : "systemGrayColor"
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,33 @@
{
"colors" : [
{
"color" : {
"platform" : "ios",
"reference" : "systemGrayColor"
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0xD2",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.220",
"green" : "0.251",
"red" : "0.910"
"blue" : "0x38",
"green" : "0x40",
"red" : "0xE8"
}
},
"idiom" : "universal"

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.827",
"red" : "1.000"
"blue" : "0x00",
"green" : "0xD2",
"red" : "0xFF"
}
},
"idiom" : "universal"

@ -1,86 +0,0 @@
//
// Club+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension Club {
var isValid: Bool {
name.isEmpty == false && name.count > 3
}
func automaticShortName() -> String {
name.acronym()
}
enum AcronymMode: String, CaseIterable {
case automatic = "Automatique"
case custom = "Personalisée"
}
func shortNameMode() -> AcronymMode {
(acronym.isEmpty || acronym == automaticShortName()) ? .automatic : .custom
}
func hasTenupId() -> Bool {
code != nil
}
func federalLink() -> URL? {
guard let code else { return nil }
return URL(string: "https://tenup.fft.fr/club/\(code)")
}
func courtName(atIndex courtIndex: Int) -> String {
courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex)
}
func courtNameIfAvailable(atIndex courtIndex: Int) -> String? {
customizedCourts.first(where: { $0.index == courtIndex })?.name
}
func update(fromClub club: Club) {
self.acronym = club.acronym
self.name = club.name
self.phone = club.phone
self.code = club.code
self.address = club.address
self.city = club.city
self.zipCode = club.zipCode
self.latitude = club.latitude
self.longitude = club.longitude
}
func hasBeenCreated(by creatorId: String?) -> Bool {
return creatorId == creator || creator == nil
}
func isFavorite() -> Bool {
return DataStore.shared.user.clubs.contains(where: { $0 == self.id })
}
static func findOrCreate(name: String, code: String?, city: String? = nil, zipCode: String? = nil) -> Club {
/*
identify a club : code, name, ??
*/
let club: Club? = DataStore.shared.clubs.first(where: { (code == nil && $0.name == name && $0.city == city && $0.zipCode == zipCode) || code != nil && $0.code == code })
if let club {
return club
} else {
return Club(creator: StoreCenter.main.userId, name: name, code: code, city: city, zipCode: zipCode)
}
}
func shareURL() -> URL? {
return URL(string: URLs.main.url.appending(path: "?club=\(id)").absoluteString.removingPercentEncoding!)
}
}

@ -6,6 +6,7 @@
//
import Foundation
import PadelClubData
extension ImportedPlayer: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
@ -13,7 +14,13 @@ extension ImportedPlayer: PlayerHolder {
return getRank()?.femaleInMaleAssimilation
}
var computedAge: Int? { nil }
var computedAge: Int? {
let year = Calendar.current.getSportAge()
if let yearOfBirth = birthYear?.toInt() {
return year - yearOfBirth
}
return nil
}
var tournamentPlayed: Int? {
Int(tournamentCount)
@ -52,6 +59,10 @@ extension ImportedPlayer: PlayerHolder {
male
}
func pasteData(withRank: Bool = false) -> String {
return [firstName?.capitalized, lastName?.capitalized, license?.computedLicense, withRank ? "(\(rank.ordinalFormatted(feminine: isMalePlayer() == false)))" : nil].compactMap({ $0 }).joined(separator: " ")
}
func isNotFromCurrentDate() -> Bool {
if let importDate, importDate != SourceFileManager.shared.lastDataSourceDate() {
return true
@ -60,7 +71,12 @@ extension ImportedPlayer: PlayerHolder {
}
}
func hitForSearch(_ searchText: String) -> Int {
func contains(_ searchField: String) -> Bool {
firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true
}
func hitForSearch(_ searchText: String?) -> Int {
guard let searchText else { return 0 }
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ")
@ -96,10 +112,26 @@ extension ImportedPlayer: PlayerHolder {
}
return 0
}
func getBirthYear() -> Int? {
if let birthYear {
return Int(birthYear)
} else {
return nil
}
}
func getProgression() -> Int {
return Int(progression)
}
func getComputedRank() -> Int? {
nil
}
}
fileprivate extension Int {
var femaleInMaleAssimilation: Int {
self + TournamentCategory.femaleInMaleAssimilationAddition(self)
self + TournamentCategory.femaleInMaleAssimilationAddition(self, seasonYear: Date.now.seasonYear())
}
}

@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
<key>_XCCurrentVersionName</key>
<string>Model_1_1.xcdatamodel</string>
</dict>
</plist>

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E214" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="v1.1">
<entity name="ImportedPlayer" representedClassName=".ImportedPlayer" syncable="YES" codeGenerationType="class">
<attribute name="assimilation" attributeType="String"/>
<attribute name="bestRank" optional="YES" attributeType="String"/>
<attribute name="birthYear" optional="YES" attributeType="String"/>
<attribute name="canonicalFirstName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(firstName)"/>
<attribute name="canonicalFullName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(fullName)"/>
<attribute name="canonicalLastName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(lastName)"/>
<attribute name="clubCode" attributeType="String"/>
<attribute name="clubName" attributeType="String"/>
<attribute name="country" attributeType="String"/>
<attribute name="firstName" attributeType="String"/>
<attribute name="fullName" attributeType="String"/>
<attribute name="importDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastName" attributeType="String"/>
<attribute name="license" attributeType="String"/>
<attribute name="ligueName" attributeType="String"/>
<attribute name="male" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="points" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="progression" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rank" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tournamentCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="license"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

@ -10,7 +10,27 @@ import CoreData
class PersistenceController: NSObject {
static let shared = PersistenceController()
private static var prepopulatedSeed: URL? {
let url = Bundle.main.url(forResource: "local", withExtension: "sqlite")
print("prepopulatedSeed", url)
return url
}
private static var _model: NSManagedObjectModel?
static func getModelVersion() -> String? {
if let versions = _model?.versionIdentifiers {
let currentVersion = versions.compactMap { $0 as? String }.joined(separator: ",")
//print("Current Model Version: \(currentVersion)")
// Compare the current model version with the saved version
return currentVersion
}
return nil
}
private static func model(name: String) throws -> NSManagedObjectModel {
if _model == nil {
_model = try loadModel(name: name, bundle: Bundle.main)
@ -39,6 +59,19 @@ class PersistenceController: NSObject {
let localStoreFolderURL = storeFolderURL.appendingPathComponent("Local")
let fileManager = FileManager.default
var firstLaunch = false
if fileManager.fileExists(atPath: localStoreFolderURL.appendingPathComponent("local.sqlite").path) == false {
firstLaunch = true
}
do {
let contents = try fileManager.contentsOfDirectory(atPath: localStoreFolderURL.path)
print("Directory contents: \(contents)")
} catch {
print("Failed to list directory contents: \(error)")
}
for folderURL in [localStoreFolderURL] where !fileManager.fileExists(atPath: folderURL.path) {
do {
try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
@ -47,6 +80,17 @@ class PersistenceController: NSObject {
}
}
if firstLaunch, let seedURL = PersistenceController.prepopulatedSeed {
let folderPath = seedURL.path()
let localStoreFileURL = localStoreFolderURL.appendingPathComponent("local.sqlite")
do {
try fileManager.copyItem(at: seedURL, to: localStoreFileURL)
} catch {
print("Error info: \(error)")
}
}
let container = NSPersistentContainer(name: "PadelClubApp", managedObjectModel: try! Self.model(name: "PadelClubApp"))
guard let localStoreDescription = container.persistentStoreDescriptions.first!.copy() as? NSPersistentStoreDescription else {
@ -175,6 +219,9 @@ class PersistenceController: NSObject {
importedPlayer.clubCode = data.clubCode.replaceCharactersFromSet(characterSet: .whitespaces)
importedPlayer.male = data.isMale
importedPlayer.importDate = importingDate
importedPlayer.progression = Int64(data.progression)
importedPlayer.bestRank = data.bestRank?.formattedAsRawString()
importedPlayer.birthYear = data.birthYear?.formattedAsRawString()
}
// 5

@ -1,73 +0,0 @@
//
// TournamentCategory+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension TournamentCategory {
}
extension TournamentType {
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .classic:
return "Classique"
case .doubleBrackets:
return "Double Poules"
}
}
}
extension TournamentBuild {
var computedLabel: String {
if age == .senior { return localizedLabel() }
return localizedLabel() + " " + localizedAge
}
var localizedTitle: String {
level.localizedLabel() + " " + category.localizedLabel()
}
}
extension TeamPosition {
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
var shortName: String {
switch self {
case .one:
return "#1"
case .two:
return "#2"
}
}
switch displayStyle {
case .wide, .title:
return "Équipe " + shortName
case .short:
return shortName
}
}
}
extension MatchFormat {
func formattedEstimatedBreakDuration() -> String {
var label = Duration.seconds(breakTime.breakTime * 60).formatted(.units(allowed: [.minutes]))
if breakTime.matchCount > 1 {
label += " après \(breakTime.matchCount) match"
label += breakTime.matchCount.pluralSuffix
}
return label
}
}

@ -20,6 +20,9 @@ class FederalPlayer: Decodable {
var clubCode: String
var club: String
var isMale: Bool
var birthYear: Int?
var progression: Int
var bestRank: Int?
// MARK: - Nationnalite
@ -28,54 +31,75 @@ class FederalPlayer: Decodable {
}
required init(from decoder: Decoder) throws {
/*
"classement": 9,
"evolution": 2,
"nom": "PEREZ LE TIEC",
"prenom": "Pierre",
"meilleurClassement": null,
"nationalite": "FRA",
"ageSportif": 30,
"points": 14210,
"nombreTournoisJoues": 24,
"ligue": "ILE DE FRANCE",
"assimilation": false
*/
enum CodingKeys: String, CodingKey {
case nom
case prenom
case licence
case meilleurClassement
case nationnalite
case anneeNaissance
case nationalite
case codeClub
case nomClub
case nomLigue
case rang
case progression
case ligue
case classement
case evolution
case points
case nombreDeTournois
case assimile
case nombreTournoisJoues
case assimilation
case ageSportif
}
let container = try decoder.container(keyedBy: CodingKeys.self)
isMale = (decoder.userInfo[.maleData] as? Bool) == true
let _lastName = try container.decode(String.self, forKey: .nom)
let _firstName = try container.decode(String.self, forKey: .prenom)
lastName = _lastName
firstName = _firstName
let _lastName = try container.decodeIfPresent(String.self, forKey: .nom)
let _firstName = try container.decodeIfPresent(String.self, forKey: .prenom)
lastName = _lastName ?? ""
firstName = _firstName ?? ""
if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) {
license = String(lic)
} else {
license = ""
}
let nationnalite = try container.decode(Nationnalite.self, forKey: .nationnalite)
country = nationnalite.code
//meilleurClassement = try container.decode(Int.self, forKey: .meilleurClassement)
//anneeNaissance = try container.decode(Int.self, forKey: .anneeNaissance)
clubCode = try container.decode(String.self, forKey: .codeClub)
club = try container.decode(String.self, forKey: .nomClub)
ligue = try container.decode(String.self, forKey: .nomLigue)
rank = try container.decode(Int.self, forKey: .rang)
//progression = try? container.decodeIfPresent(Int.self, forKey: .progression)
country = try container.decodeIfPresent(String.self, forKey: .nationalite) ?? ""
bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement)
let ageSportif = try container.decodeIfPresent(Int.self, forKey: .ageSportif)
if let ageSportif {
let month = Calendar.current.component(.month, from: Date())
if month > 8 {
birthYear = Calendar.current.component(.year, from: Date()) + 1 - ageSportif
} else {
birthYear = Calendar.current.component(.year, from: Date()) - ageSportif
}
}
clubCode = try container.decodeIfPresent(String.self, forKey: .codeClub) ?? ""
club = try container.decodeIfPresent(String.self, forKey: .nomClub) ?? ""
ligue = try container.decodeIfPresent(String.self, forKey: .ligue) ?? ""
rank = try container.decode(Int.self, forKey: .classement)
progression = (try? container.decodeIfPresent(Int.self, forKey: .evolution)) ?? 0
let pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points)
if let pointsAsInt {
points = Double(pointsAsInt)
} else {
points = nil
}
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreDeTournois)
let assimile = try container.decode(Bool.self, forKey: .assimile)
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreTournoisJoues)
let assimile = try container.decode(Bool.self, forKey: .assimilation)
assimilation = assimile ? "Oui" : "Non"
}
@ -84,11 +108,12 @@ class FederalPlayer: Decodable {
let pointsString = points != nil ? String(Int(points!)) : ""
let tournamentCountString = tournamentCount != nil ? String(tournamentCount!) : ""
let strippedLicense = license.strippedLicense ?? ""
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);"
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);\(progression.formattedAsRawString());\(bestRank?.formattedAsRawString() ?? "");\(birthYear?.formattedAsRawString() ?? "");"
return line
}
func formatNumbers(_ input: String) -> String {
if input.isEmpty { return input }
// Insert spaces at appropriate positions
let formattedString = insertSeparator(input, separator: " ", every: [2, 4])
return formattedString
@ -167,6 +192,9 @@ class FederalPlayer: Decodable {
ligue = result[8]
clubCode = result[9]
club = result[10]
progression = result[safe: 11]?.toInt() ?? 0
bestRank = result[safe: 12]?.toInt()
birthYear = result[safe: 13]?.toInt()
}
static func anonymousCount(mostRecentDateAvailable: Date?) async -> Int? {
@ -199,8 +227,9 @@ class FederalPlayer: Decodable {
rankPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [rankPredicate, NSPredicate(format: "importDate == %@", mostRecentDateAvailable as CVarArg)])
}
fetch.predicate = rankPredicate
print(fetch.predicate)
let lastPlayersCount = try context.count(for: fetch)
print(Int(lr), Int(lastPlayersCount) - 1, count)
return (Int(lr) + Int(lastPlayersCount) - 1, count)
}
} catch {

@ -6,15 +6,11 @@
import Foundation
import CoreLocation
import LeStorage
import PadelClubData
enum DayPeriod {
case all
case weekend
case week
}
// MARK: - FederalTournament
struct FederalTournament: Identifiable, Codable {
struct FederalTournament: Identifiable, Codable, Hashable {
func getEvent() -> Event {
let club = DataStore.shared.user.clubsObjects().first(where: { $0.code == codeClub })
@ -27,6 +23,14 @@ struct FederalTournament: Identifiable, Codable {
Logger.error(error)
}
}
if let club, club.creator == nil {
club.creator = StoreCenter.main.userId
do {
try DataStore.shared.clubs.addOrUpdate(instance: club)
} catch {
Logger.error(error)
}
}
return event!
}
@ -37,7 +41,7 @@ struct FederalTournament: Identifiable, Codable {
}
let id: Int
let id: String
var millesime: Int?
var libelle: String?
var tmc: Bool?
@ -77,7 +81,105 @@ struct FederalTournament: Identifiable, Codable {
var dateFin, dateValidation: Date?
var codePostalEngagement, codeClub: String?
var prixEspece: Int?
var distanceEnMetres: Double?
var japPhoneNumber: String?
mutating func updateJapPhoneNumber(phone: String?) {
self.japPhoneNumber = phone
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle id that could be string or int
if let idString = try? container.decode(String.self, forKey: .id) {
id = idString
} else if let idInt = try? container.decode(Int.self, forKey: .id) {
id = String(idInt)
} else {
throw DecodingError.dataCorruptedError(forKey: .id, in: container,
debugDescription: "Expected String or Int for id")
}
// Regular decoding for simple properties
millesime = try container.decodeIfPresent(Int.self, forKey: .millesime)
libelle = try container.decodeIfPresent(String.self, forKey: .libelle)
tmc = try container.decodeIfPresent(Bool.self, forKey: .tmc)
tarifAdulteChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifAdulteChampionnat)
type = try container.decodeIfPresent(String.self, forKey: .type)
ageReel = try container.decodeIfPresent(Bool.self, forKey: .ageReel)
naturesTerrains = try container.decodeIfPresent([JSONAny].self, forKey: .naturesTerrains)
idsArbitres = try container.decodeIfPresent([JSONAny].self, forKey: .idsArbitres)
tarifJeuneChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifJeuneChampionnat)
international = try container.decodeIfPresent(Bool.self, forKey: .international)
inscriptionEnLigne = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigne)
categorieTournoi = try container.decodeIfPresent(CategorieTournoi.self, forKey: .categorieTournoi)
prixLot = try container.decodeIfPresent(Int.self, forKey: .prixLot)
paiementEnLigne = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigne)
reductionAdherentJeune = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentJeune)
reductionAdherentAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentAdulte)
paiementEnLigneObligatoire = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigneObligatoire)
villeEngagement = try container.decodeIfPresent(String.self, forKey: .villeEngagement)
senior = try container.decodeIfPresent(Bool.self, forKey: .senior)
veteran = try container.decodeIfPresent(Bool.self, forKey: .veteran)
inscriptionEnLigneEnCours = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigneEnCours)
avecResultatPublie = try container.decodeIfPresent(Bool.self, forKey: .avecResultatPublie)
code = try container.decodeIfPresent(String.self, forKey: .code)
categorieAge = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAge)
codeComite = try container.decodeIfPresent(String.self, forKey: .codeComite)
installations = try container.decodeIfPresent([JSONAny].self, forKey: .installations)
reductionEpreuveSupplementaireJeune = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireJeune)
reductionEpreuveSupplementaireAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireAdulte)
nomComite = try container.decodeIfPresent(String.self, forKey: .nomComite)
naturesEpreuves = try container.decodeIfPresent([Serie].self, forKey: .naturesEpreuves)
jeune = try container.decodeIfPresent(Bool.self, forKey: .jeune)
courrielEngagement = try container.decodeIfPresent(String.self, forKey: .courrielEngagement)
nomClub = try container.decodeIfPresent(String.self, forKey: .nomClub)
installation = try container.decodeIfPresent(Installation.self, forKey: .installation)
categorieAgeMax = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAgeMax)
tournoiInterne = try container.decodeIfPresent(Bool.self, forKey: .tournoiInterne)
nomLigue = try container.decodeIfPresent(String.self, forKey: .nomLigue)
nomEngagement = try container.decodeIfPresent(String.self, forKey: .nomEngagement)
codeLigue = try container.decodeIfPresent(String.self, forKey: .codeLigue)
modeleDeBalle = try container.decodeIfPresent(ModeleDeBalle.self, forKey: .modeleDeBalle)
jugeArbitre = try container.decodeIfPresent(JugeArbitre.self, forKey: .jugeArbitre)
adresse2Engagement = try container.decodeIfPresent(String.self, forKey: .adresse2Engagement)
epreuves = try container.decodeIfPresent([Epreuve].self, forKey: .epreuves)
serie = try container.decodeIfPresent(Serie.self, forKey: .serie)
codePostalEngagement = try container.decodeIfPresent(String.self, forKey: .codePostalEngagement)
codeClub = try container.decodeIfPresent(String.self, forKey: .codeClub)
prixEspece = try container.decodeIfPresent(Int.self, forKey: .prixEspece)
// Custom decoding for dateDebut
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateDebut) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateDebut = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateFin
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateFin) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateFin = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateValidation
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateValidation) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateValidation = dateFormatter.date(from: dateString)
}
}
}
private enum DateKeys: String, CodingKey {
case date
}
var dayPeriod: DayPeriod {
if let dateDebut {
@ -126,10 +228,47 @@ struct FederalTournament: Identifiable, Codable {
?? []
}
var federalClub: FederalClub? {
if let codeClub {
return FederalClub(federalClubCode: codeClub, federalClubName: clubLabel())
} else {
return nil
}
}
var shareMessage: String {
[libelle, dateDebut?.formatted(date: .complete, time: .omitted)].compactMap({$0}).joined(separator: "\n") + "\n"
}
var sharePartnerMessage: String {
["Je nous ai inscris au tournoi suivant : ",
libelle,
dateDebut?.formatted(date: .complete, time: .omitted),
"message preparé par Padel Club",
URLs.appStore.rawValue
].compactMap({$0}).joined(separator: "\n") + "\n"
}
func calendarNoteMessage() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: "\n")
}
var japMessage: String {
[nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, japPhoneNumber].compactMap({$0}).joined(separator: ";")
}
func umpireLabel() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).map({ $0.lowercased().capitalized }).joined(separator: " ")
}
func phoneLabel() -> String {
[installation?.telephone].compactMap({$0}).joined(separator: " ")
}
func mailLabel() -> String {
[courrielEngagement].compactMap({$0}).joined(separator: " ")
}
func validForSearch(_ searchText: String, scope: FederalTournamentSearchScope) -> Bool {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
@ -156,18 +295,25 @@ extension FederalTournament: FederalTournamentHolder {
// var importedId: Int { id }
var holderId: String { id.string }
func clubLabel() -> String {
nomClub ?? villeEngagement ?? installation?.nom ?? ""
}
func subtitleLabel() -> String {
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
""
}
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String {
build.level.localizedLevelLabel(displayStyle)
}
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool {
true
}
}
// MARK: - CategorieAge
struct CategorieAge: Codable {
struct CategorieAge: Codable, Hashable {
var ageJoueurMin, ageMin, ageJoueurMax, ageRechercheMax: Int?
var categoriesAgeTypePratique: [CategoriesAgeTypePratique]?
var ageMax: Int?
@ -179,35 +325,28 @@ struct CategorieAge: Codable {
var tournamentAge: FederalTournamentAge? {
if let id {
return FederalTournamentAge(rawValue: id)
return FederalTournamentAge(rawValue: id) ?? .senior
}
if let libelle {
return FederalTournamentAge.allCases.first(where: { $0.localizedLabel().localizedCaseInsensitiveContains(libelle) })
return FederalTournamentAge.allCases.first(where: { $0.localizedFederalAgeLabel().localizedCaseInsensitiveContains(libelle) }) ?? .senior
}
return nil
return .senior
}
}
// MARK: - CategoriesAgeTypePratique
struct CategoriesAgeTypePratique: Codable {
struct CategoriesAgeTypePratique: Codable, Hashable {
var id: ID?
}
// MARK: - ID
struct ID: Codable {
var typePratique: TypePratique?
struct ID: Codable, Hashable {
var typePratique: String?
var idCategorieAge: Int?
}
enum TypePratique: String, Codable {
case beach = "BEACH"
case padel = "PADEL"
case tennis = "TENNIS"
case pickle = "PICKLE"
}
// MARK: - CategorieTournoi
struct CategorieTournoi: Codable {
struct CategorieTournoi: Codable, Hashable {
var code, codeTaxe: String?
var compteurGda: CompteurGda?
var libelle, niveauHierarchique: String?
@ -215,14 +354,14 @@ struct CategorieTournoi: Codable {
}
// MARK: - CompteurGda
struct CompteurGda: Codable {
struct CompteurGda: Codable, Hashable {
var classementMax: Classement?
var libelle: String?
var classementMin: Classement?
}
// MARK: - Classement
struct Classement: Codable {
struct Classement: Codable, Hashable {
var nature, libelle: String?
var serie: Serie?
var sexe: String?
@ -232,18 +371,18 @@ struct Classement: Codable {
}
// MARK: - Serie
struct Serie: Codable {
struct Serie: Codable, Hashable {
var code, libelle: String?
var valide: Bool?
var sexe: String?
var tournamentCategory: TournamentCategory? {
TournamentCategory.allCases.first(where: { $0.requestLabel == code })
TournamentCategory.allCases.first(where: { $0.requestLabel == code }) ?? .men
}
}
// MARK: - Epreuve
struct Epreuve: Codable {
struct Epreuve: Codable, Hashable {
var inscriptionEnLigneEnCours: Bool?
var categorieAge: CategorieAge?
var typeEpreuve: TypeEpreuve?
@ -280,7 +419,7 @@ struct Epreuve: Codable {
}
// MARK: - TypeEpreuve
struct TypeEpreuve: Codable {
struct TypeEpreuve: Codable, Hashable {
let code: String?
let delai: Int?
let libelle: String?
@ -291,19 +430,19 @@ struct TypeEpreuve: Codable {
var tournamentLevel: TournamentLevel? {
if let code, let value = Int(code.removingFirstCharacter) {
return TournamentLevel(rawValue: value)
return TournamentLevel(rawValue: value) ?? .p100
}
return nil
return .p100
}
}
// MARK: - BorneAnneesNaissance
struct BorneAnneesNaissance: Codable {
struct BorneAnneesNaissance: Codable, Hashable {
var min, max: Int?
}
// MARK: - Installation
struct Installation: Codable {
struct Installation: Codable, Hashable {
var ville: String?
var lng: Double?
var surfaces: [JSONAny]?
@ -318,7 +457,7 @@ struct Installation: Codable {
}
// MARK: - JugeArbitre
struct JugeArbitre: Codable {
struct JugeArbitre: Codable, Hashable {
var idCRM, id: Int?
var nom, prenom: String?
@ -329,7 +468,7 @@ struct JugeArbitre: Codable {
}
// MARK: - ModeleDeBalle
struct ModeleDeBalle: Codable {
struct ModeleDeBalle: Codable, Hashable {
var libelle: String?
var marqueDeBalle: MarqueDeBalle?
var id: Int?
@ -337,7 +476,7 @@ struct ModeleDeBalle: Codable {
}
// MARK: - MarqueDeBalle
struct MarqueDeBalle: Codable {
struct MarqueDeBalle: Codable, Hashable {
var id: Int?
var valide: Bool?
var marque: String?
@ -390,9 +529,13 @@ class JSONCodingKey: CodingKey {
}
}
class JSONAny: Codable {
class JSONAny: Codable, Hashable, Equatable {
var value: Any
let value: Any
init() {
self.value = ()
}
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
@ -583,4 +726,70 @@ class JSONAny: Codable {
try JSONAny.encode(to: &container, value: self.value)
}
}
public static func == (lhs: JSONAny, rhs: JSONAny) -> Bool {
switch (lhs.value, rhs.value) {
case (let l as Bool, let r as Bool): return l == r
case (let l as Int64, let r as Int64): return l == r
case (let l as Double, let r as Double): return l == r
case (let l as String, let r as String): return l == r
case (let l as JSONNull, let r as JSONNull): return true
case (let l as [Any], let r as [Any]):
guard l.count == r.count else { return false }
return zip(l, r).allSatisfy { (a, b) in
// Recursively wrap in JSONAny for comparison
JSONAny(value: a) == JSONAny(value: b)
}
case (let l as [String: Any], let r as [String: Any]):
guard l.count == r.count else { return false }
for (key, lVal) in l {
guard let rVal = r[key], JSONAny(value: lVal) == JSONAny(value: rVal) else { return false }
}
return true
default:
return false
}
}
public func hash(into hasher: inout Hasher) {
switch value {
case let v as Bool:
hasher.combine(0)
hasher.combine(v)
case let v as Int64:
hasher.combine(1)
hasher.combine(v)
case let v as Double:
hasher.combine(2)
hasher.combine(v)
case let v as String:
hasher.combine(3)
hasher.combine(v)
case is JSONNull:
hasher.combine(4)
case let v as [Any]:
hasher.combine(5)
for elem in v {
JSONAny(value: elem).hash(into: &hasher)
}
case let v as [String: Any]:
hasher.combine(6)
// Order of hashing dictionary keys shouldn't matter
for key in v.keys.sorted() {
hasher.combine(key)
if let val = v[key] {
JSONAny(value: val).hash(into: &hasher)
}
}
default:
hasher.combine(-1)
}
}
// Helper init for internal use
convenience init(value: Any) {
self.init()
self.value = value
}
}

@ -6,19 +6,23 @@
//
import Foundation
import PadelClubData
protocol FederalTournamentHolder {
var holderId: String { get }
var startDate: Date { get }
var endDate: Date? { get }
var codeClub: String? { get }
var tournaments: [any TournamentBuildHolder] { get }
func clubLabel() -> String
func subtitleLabel() -> String
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String
var dayDuration: Int { get }
var dayPeriod: DayPeriod { get }
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool
}
extension FederalTournamentHolder {
func durationLabel() -> String {
switch dayDuration {
case 1:

@ -6,6 +6,7 @@
//
import Foundation
import SwiftUI
protocol PlayerHolder {
@ -24,6 +25,9 @@ protocol PlayerHolder {
var computedAge: Int? { get }
func getAssimilatedAsMaleRank() -> Int?
func isNotFromCurrentDate() -> Bool
func getBirthYear() -> Int?
func getProgression() -> Int
func getComputedRank() -> Int?
}
extension PlayerHolder {
@ -38,4 +42,16 @@ extension PlayerHolder {
func isAnonymous() -> Bool {
getFirstName().isEmpty && getLastName().isEmpty
}
func getProgressionColor(progression: Int) -> Color {
switch progression {
case _ where progression > 0:
return Color.green
case _ where progression < 0:
return Color.logoRed
default:
return Color.primary
}
}
}

@ -1,55 +0,0 @@
//
// GroupStage+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension GroupStage {
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let name { return name }
switch displayStyle {
case .wide, .title:
return "Poule \(index + 1)"
case .short:
return "#\(index + 1)"
}
}
func pasteData() -> String {
var data: [String] = []
data.append(self.groupStageTitle())
teams().forEach { team in
data.append(team.teamLabelRanked(displayRank: true, displayTeamName: true))
}
return data.joined(separator: "\n")
}
}
extension GroupStage: Selectable {
func selectionLabel(index: Int) -> String {
groupStageTitle()
}
func badgeValue() -> Int? {
return runningMatches(playedMatches: _matches()).count
}
func badgeValueColor() -> Color? {
return nil
}
func badgeImage() -> Badge? {
if teams().count < size {
return .xmark
} else {
return hasEnded() ? .checkmark : nil
}
}
}

@ -1,89 +0,0 @@
//
// Match+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension Match {
func matchWarningSubject() -> String {
[roundTitle(), matchTitle(.short)].compacted().joined(separator: " ")
}
func matchWarningMessage() -> String {
[roundTitle(), matchTitle(.short), startDate?.localizedDate(), courtName()].compacted().joined(separator: "\n")
}
func matchTitle(_ displayStyle: DisplayStyle = .wide, inMatches matches: [Match]? = nil) -> String {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func matchTitle", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if let groupStageObject {
return groupStageObject.localizedMatchUpLabel(for: index)
}
switch displayStyle {
case .wide, .title:
return "Match \(indexInRound(in: matches) + 1)"
case .short:
return "#\(indexInRound(in: matches) + 1)"
}
}
func roundTitle() -> String? {
if groupStage != nil { return groupStageObject?.groupStageTitle() }
else if let roundObject { return roundObject.roundTitle() }
else { return nil }
}
func teamNames(_ team: TeamRegistration?) -> [String]? {
return team?.players().map { $0.playerLabel() }
}
func updateScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
let teamScoreOne = teamScore(.one) ?? TeamScore(match: id, team: team(.one))
teamScoreOne.score = matchDescriptor.teamOneScores.joined(separator: ",")
let teamScoreTwo = teamScore(.two) ?? TeamScore(match: id, team: team(.two))
teamScoreTwo.score = matchDescriptor.teamTwoScores.joined(separator: ",")
do {
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo])
} catch {
Logger.error(error)
}
matchFormat = matchDescriptor.matchFormat
}
func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
updateScore(fromMatchDescriptor: matchDescriptor)
if endDate == nil {
endDate = Date()
}
if startDate == nil {
startDate = endDate?.addingTimeInterval(Double(-getDuration()*60))
}
let teamOne = team(matchDescriptor.winner)
let teamTwo = team(matchDescriptor.winner.otherTeam)
teamOne?.hasArrived()
teamTwo?.hasArrived()
winningTeamId = teamOne?.id
losingTeamId = teamTwo?.id
confirmed = true
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
updateFollowingMatchTeamScore()
}
}

@ -1,13 +0,0 @@
//
// MatchScheduler+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension MatchScheduler {
}

@ -1,230 +0,0 @@
//
// PlayerRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension PlayerRegistration {
internal init(importedPlayer: ImportedPlayer) {
self.teamRegistration = ""
self.firstName = (importedPlayer.firstName ?? "").trimmed.capitalized
self.lastName = (importedPlayer.lastName ?? "").trimmed.uppercased()
self.licenceId = importedPlayer.license ?? nil
self.rank = Int(importedPlayer.rank)
self.sex = importedPlayer.male ? .male : .female
self.tournamentPlayed = importedPlayer.tournamentPlayed
self.points = importedPlayer.getPoints()
self.clubName = importedPlayer.clubName
self.ligueName = importedPlayer.ligueName
self.assimilation = importedPlayer.assimilation
self.source = .frenchFederation
}
internal init?(federalData: [String], sex: Int, sexUnknown: Bool) {
let _lastName = federalData[0].trimmed.uppercased()
let _firstName = federalData[1].trimmed.capitalized
if _lastName.isEmpty && _firstName.isEmpty { return nil }
lastName = _lastName
firstName = _firstName
birthdate = federalData[2]
licenceId = federalData[3]
clubName = federalData[4]
let stringRank = federalData[5]
if stringRank.isEmpty {
rank = nil
} else {
rank = Int(stringRank)
}
let _email = federalData[6]
if _email.isEmpty == false {
self.email = _email
}
let _phoneNumber = federalData[7]
if _phoneNumber.isEmpty == false {
self.phoneNumber = _phoneNumber
}
source = .beachPadel
if sexUnknown {
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) {
self.sex = .female
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) {
self.sex = .male
} else {
self.sex = nil
}
} else {
self.sex = PlayerSexType(rawValue: sex)
}
}
func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
case .wide, .title:
return lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized
case .short:
let names = lastName.components(separatedBy: .whitespaces)
if lastName.components(separatedBy: .whitespaces).count > 1 {
if let firstLongWord = names.first(where: { $0.count > 3 }) {
return firstLongWord.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "."
}
}
return lastName.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "."
}
}
func pasteData(_ exportFormat: ExportFormat = .rawText) -> String {
switch exportFormat {
case .rawText:
return [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: exportFormat.separator())
case .csv:
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator())
}
}
@objc
var canonicalName: String {
playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased()
}
func isValidLicenseNumber(year: Int) -> Bool {
guard let licenceId else { return false }
guard licenceId.isLicenseNumber else { return false }
guard licenceId.suffix(6) == "(\(year))" else { return false }
return true
}
@MainActor
func updateRank(from sources: [CSVParser], lastRank: Int) async throws {
if let dataFound = try await history(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else {
rank = lastRank
}
}
}
extension PlayerRegistration: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
nil
}
func getFirstName() -> String {
firstName
}
func getLastName() -> String {
lastName
}
func getPoints() -> Double? {
self.points
}
func getRank() -> Int? {
rank
}
func isUnranked() -> Bool {
rank == nil
}
func formattedRank() -> String {
self.rankLabel()
}
func formattedLicense() -> String {
if let licenceId { return licenceId.computedLicense }
return "aucune licence"
}
var male: Bool {
isMalePlayer()
}
func history(from sources: [CSVParser]) async throws -> Line? {
guard let license = licenceId?.strippedLicense else {
return try await historyFromName(from: sources)
}
return await withTaskGroup(of: Line?.self) { group in
for source in sources.filter({ $0.maleData == isMalePlayer() }) {
group.addTask {
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first(where: { line in
line.rawValue.contains(";\(license);")
})
}
}
if let first = await group.first(where: { $0 != nil }) {
group.cancelAll()
return first
} else {
return nil
}
}
}
func historyFromName(from sources: [CSVParser]) async throws -> Line? {
return await withTaskGroup(of: Line?.self) { group in
for source in sources.filter({ $0.maleData == isMalePlayer() }) {
group.addTask { [lastName, firstName] in
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first(where: { line in
line.rawValue.canonicalVersionWithPunctuation.contains(";\(lastName.canonicalVersionWithPunctuation);\(firstName.canonicalVersionWithPunctuation);")
})
}
}
if let first = await group.first(where: { $0 != nil }) {
group.cancelAll()
return first
} else {
return nil
}
}
}
func validateLicenceId(_ year: Int) {
if let currentLicenceId = licenceId {
if currentLicenceId.trimmed.hasSuffix("(\(year-1))") {
self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)")
} else if let computedLicense = currentLicenceId.strippedLicense {
self.licenceId = computedLicense + " (\(year))"
}
}
}
func hasHomonym() -> Bool {
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName)
fetchRequest.predicate = predicate
do {
let count = try federalContext.count(for: fetchRequest)
return count > 1
} catch {
}
return false
}
func hasInvalidLicence() -> Bool {
return (self.isImported() && self.isValidLicenseNumber(year: licenseYearValidity) == false) ||
(self.isImported() == false &&
(self.licenceId == nil || self.formattedLicense().isLicenseNumber == false || self.licenceId?.isEmpty == true))
}
}

@ -1,21 +0,0 @@
# Procédure d'ajout de champ dans une classe
Dans Swift:
- Ajouter le champ dans classe
- Ajouter le champ dans le constructeur si possible
- Ajouter la codingKey correspondante
- Ajouter le champ dans l'encoding
- Ouvrir **ServerDataTests** et ajouter un test sur le champ
- Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple
Dans Django:
- Ajouter le champ dans la classe
- S'il c'est un champ dans **CustomUser**:
- Ajouter le champ à la méthode fields_for_update
- Ajouter le champ dans UserSerializer > create > create_user dans serializers.py
- L'ajouter aussi dans admin.py si nécéssaire
- Faire le *makemigrations* + *migrate*
Enfin, revenir dans Xcode, ouvrir ServerDataTests et lancer le test mis à jour

@ -1,155 +0,0 @@
//
// Round+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension Round {
static func setServerTitle(upperRound: Round, matchIndex: Int) -> String {
if upperRound.index == 0 { return upperRound.roundTitle() }
return upperRound.roundTitle() + " #" + (matchIndex + 1).formatted()
}
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String {
if parent != nil {
if let seedInterval = seedInterval(initialMode: initialMode) {
return seedInterval.localizedLabel(displayStyle)
}
print("Round pas trouvé", id, parent, index)
return "Match de classement"
}
return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle)
}
func pasteData() -> String {
var data: [String] = []
data.append(self.roundTitle())
playedMatches().forEach { match in
data.append(match.matchTitle())
data.append(match.team(.one)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
data.append(match.team(.two)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
}
return data.joined(separator: "\n")
}
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func correspondingLoserRoundTitle()", duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
let seedsAfterThisRound: [TeamRegistration] = self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
}
// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: {
// $0.tournament == tournament
// && $0.bracketPosition != nil
// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
// })
let playedMatches = playedMatches()
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count)
return seedInterval.localizedLabel(displayStyle)
}
func buildLoserBracket() {
guard loserRounds().isEmpty else { return }
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
guard currentRoundMatchCount > 1 else { return }
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
var loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat
if let parentRound {
loserBracketMatchFormat = tournamentObject()?.loserBracketSmartMatchFormat(parentRound.index)
}
let rounds = (0..<roundCount).map { //index 0 is the final
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat)
round.parent = id //parent
return round
}
do {
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds)
} catch {
Logger.error(error)
}
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount)
let matches = (0..<matchCount).map { //0 is final match
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0)
let round = rounds[roundIndex]
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat, name: round.roundTitle(initialMode: true))
//initial mode let the roundTitle give a name without considering the playable match
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
loserRounds().forEach { round in
round.buildLoserBracket()
}
}
}
extension Round: Selectable, Equatable {
static func == (lhs: Round, rhs: Round) -> Bool {
lhs.id == rhs.id
}
func selectionLabel(index: Int) -> String {
if let parentRound {
return "Tour #\(parentRound.loserRounds().count - index)"
} else {
return roundTitle(.short)
}
}
func badgeValue() -> Int? {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func badgeValue round of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if let parentRound {
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count
} else {
return playedMatches().filter({ $0.isRunning() }).count
}
}
func badgeValueColor() -> Color? {
return nil
}
func badgeImage() -> Badge? {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func badgeImage of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return hasEnded() ? .checkmark : nil
}
}

@ -1,20 +0,0 @@
//
// SeedInterval+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension SeedInterval {
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if dimension < 3 {
return "\(first)\(first.ordinalFormattedSuffix()) place"
} else {
return "Place \(first) à \(last)"
}
}
}

@ -1,109 +0,0 @@
//
// TeamRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension TeamRegistration {
func getPhoneNumbers() -> [String] {
return players().compactMap { $0.phoneNumber }.filter({ $0.isMobileNumber() })
}
func playersPasteData(_ exportFormat: ExportFormat = .rawText) -> String {
switch exportFormat {
case .rawText:
return players().map { $0.pasteData(exportFormat) }.joined(separator: exportFormat.newLineSeparator())
case .csv:
return players().map { [$0.pasteData(exportFormat), isWildCard() ? "WC" : $0.computedRank.formatted() ].joined(separator: exportFormat.separator()) }.joined(separator: exportFormat.separator())
}
}
func formattedInscriptionDate(_ exportFormat: ExportFormat = .rawText) -> String? {
switch exportFormat {
case .rawText:
if let registrationDate {
return "Inscrit le " + registrationDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
case .csv:
if let registrationDate {
return registrationDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
}
}
func formattedSummonDate(_ exportFormat: ExportFormat = .rawText) -> String? {
switch exportFormat {
case .rawText:
if let callDate {
return "Convoqué le " + callDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
case .csv:
if let callDate {
return callDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
}
}
func pasteData(_ exportFormat: ExportFormat = .rawText, _ index: Int = 0) -> String {
switch exportFormat {
case .rawText:
return [playersPasteData(exportFormat), formattedInscriptionDate(exportFormat), name].compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator())
case .csv:
return [index.formatted(), playersPasteData(exportFormat), isWildCard() ? "WC" : weight.formatted()].joined(separator: exportFormat.separator())
}
}
func positionLabel() -> String? {
if groupStagePosition != nil { return "Poule" }
if let initialRound = initialRound() {
return initialRound.roundTitle()
} else {
return nil
}
}
func initialRoundColor() -> Color? {
if walkOut { return Color.logoRed }
if groupStagePosition != nil { return Color.blue }
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] {
return Color(uiColor: .init(fromHex: colorHex))
} else {
return nil
}
}
func containsExactlyPlayerLicenses(_ playerLicenses: [String?]) -> Bool {
let arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion })
let ids : Set<String> = Set<String>(arrayOfIds.sorted())
let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted())
return ids.hashValue == searchedIds.hashValue
}
@objc
var canonicalName: String {
players().map { $0.canonicalName }.joined(separator: " ")
}
func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String {
return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ")
}
func teamLabelRanked(displayRank: Bool, displayTeamName: Bool) -> String {
[displayTeamName ? name : nil, displayRank ? seedIndex() : nil, displayTeamName ? (name == nil ? teamLabel() : name) : teamLabel()].compactMap({ $0 }).joined(separator: " ")
}
}

@ -1,395 +0,0 @@
//
// Tournament+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
import PadelClubData
extension Tournament {
func shareURL(_ pageLink: PageLink = .matches) -> URL? {
if pageLink == .clubBroadcast {
let club = club()
print("club", club)
print("club broadcast code", club?.broadcastCode)
if let club, let broadcastCode = club.broadcastCode {
return URLs.main.url.appending(path: "c/\(broadcastCode)")
} else {
return nil
}
}
return URLs.main.url.appending(path: "tournament/\(id)").appending(path: pageLink.path)
}
func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText) -> String {
let selectedSortedTeams = selectedSortedTeams()
switch exportFormat {
case .rawText:
return (selectedSortedTeams.compactMap { $0.pasteData(exportFormat) } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true).compactMap { $0.pasteData(exportFormat) }).joined(separator: exportFormat.newLineSeparator(2))
case .csv:
let headers = ["", "Nom Prénom", "rang", "Nom Prénom", "rang", "poids"].joined(separator: exportFormat.separator())
var teamPaste = [headers]
for (index, team) in selectedSortedTeams.enumerated() {
teamPaste.append(team.pasteData(exportFormat, index + 1))
}
return teamPaste.joined(separator: exportFormat.newLineSeparator())
}
}
func importTeams(_ teams: [FileImportManager.TeamHolder]) {
var teamsToImport = [TeamRegistration]()
let players = players().filter { $0.licenceId != nil }
teams.forEach { team in
if let previousTeam = team.previousTeam {
previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory)
teamsToImport.append(previousTeam)
} else {
var registrationDate = team.registrationDate
if let previousPlayer = players.first(where: { player in
let ids = team.players.compactMap({ $0.licenceId })
return ids.contains(player.licenceId!)
}), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate {
registrationDate = previousTeamRegistrationDate
}
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name)
teamsToImport.append(newTeam)
}
}
do {
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport)
} catch {
Logger.error(error)
}
do {
try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: teams.flatMap { $0.players })
} catch {
Logger.error(error)
}
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty {
setGroupStage(randomize: groupStageSortMode == .random)
}
}
func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] {
players.filter({ $0.hasHomonym() })
}
func finalRanking() async -> [Int: [String]] {
var teams: [Int: [String]] = [:]
var ids: Set<String> = Set<String>()
let rounds = rounds()
let final = rounds.last?.playedMatches().last
if let winner = final?.winningTeamId {
teams[1] = [winner]
ids.insert(winner)
}
if let finalist = final?.losingTeamId {
teams[2] = [finalist]
ids.insert(finalist)
}
let others: [Round] = rounds.flatMap { round in
let losers = round.losers()
let minimumFinalPosition = round.seedInterval()?.last ?? teamCount
if teams[minimumFinalPosition] == nil {
teams[minimumFinalPosition] = losers.map { $0.id }
} else {
teams[minimumFinalPosition]?.append(contentsOf: losers.map { $0.id })
}
print("round", round.roundTitle())
let rounds = round.loserRoundsAndChildren().filter { $0.isRankDisabled() == false && $0.hasNextRound() == false }
print(rounds.count, rounds.map { $0.roundTitle() })
return rounds
}.compactMap({ $0 })
others.forEach { round in
print("round", round.roundTitle())
if let interval = round.seedInterval() {
print("interval", interval.localizedLabel())
let playedMatches = round.playedMatches().filter { $0.disabled == false || $0.isReady() }
print("playedMatches", playedMatches.count)
let winners = playedMatches.compactMap({ $0.winningTeamId }).filter({ ids.contains($0) == false })
print("winners", winners.count)
let losers = playedMatches.compactMap({ $0.losingTeamId }).filter({ ids.contains($0) == false })
print("losers", losers.count)
if winners.isEmpty {
let disabledIds = playedMatches.flatMap({ $0.teamScores.compactMap({ $0.teamRegistration }) }).filter({ ids.contains($0) == false })
if disabledIds.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: disabledIds)
teams[interval.last] = disabledIds
let teamNames : [String] = disabledIds.compactMap {
let t : TeamRegistration? = Store.main.findById($0)
return t
}.map { $0.canonicalName }
print("winners.isEmpty", "\(interval.last) : ", teamNames)
disabledIds.forEach {
ids.insert($0)
}
}
} else {
if winners.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: winners)
teams[interval.first + winners.count - 1] = winners
let teamNames : [String] = winners.compactMap {
let t: TeamRegistration? = Store.main.findById($0)
return t
}.map { $0.canonicalName }
print("winners", "\(interval.last + winners.count - 1) : ", teamNames)
winners.forEach { ids.insert($0) }
}
if losers.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: losers)
teams[interval.last] = losers
let loserTeamNames : [String] = losers.compactMap {
let t: TeamRegistration? = Store.main.findById($0)
return t
}.map { $0.canonicalName }
print("losers", "\(interval.last) : ", loserTeamNames)
losers.forEach { ids.insert($0) }
}
}
}
}
let groupStages = groupStages()
let baseRank = teamCount - groupStageSpots() + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified
groupStages.forEach { groupStage in
let groupStageTeams = groupStage.teams(true)
for (index, team) in groupStageTeams.enumerated() {
if team.qualified == false {
let groupStageWidth = max(((index == qualifiedPerGroupStage) ? groupStageCount - groupStageAdditionalQualified : groupStageCount) * (index - qualifiedPerGroupStage), 0)
let _index = baseRank + groupStageWidth + 1
if let existingTeams = teams[_index] {
teams[_index] = existingTeams + [team.id]
} else {
teams[_index] = [team.id]
}
}
}
}
return teams
}
}
extension Tournament: FederalTournamentHolder {
var holderId: String { id }
func clubLabel() -> String {
locationLabel()
}
func subtitleLabel() -> String {
subtitle()
}
var tournaments: [any TournamentBuildHolder] {
[
self
]
}
func bracketStatus() async -> (status: String, description: String?, cut: TeamRegistration.TeamRange?) {
let availableSeeds = availableSeeds()
var description: String? = nil
if availableSeeds.isEmpty == false {
description = "placer \(availableSeeds.count) équipe\(availableSeeds.count.pluralSuffix)"
}
if description == nil {
let availableQualifiedTeams = availableQualifiedTeams()
if availableQualifiedTeams.isEmpty == false {
description = "placer \(availableQualifiedTeams.count) qualifié" + availableQualifiedTeams.count.pluralSuffix
}
}
var cut: TeamRegistration.TeamRange? = nil
if description == nil && isAnimation() == false {
cut = TeamRegistration.TeamRange(availableSeeds.first, availableSeeds.last)
}
if let round = getActiveRound() {
return ([round.roundTitle(.short), round.roundStatus()].joined(separator: " ").lowercased(), description, cut)
} else {
return ("", description, nil)
}
}
func groupStageStatus() async -> (status: String, cut: TeamRegistration.TeamRange?) {
let groupStageTeams = groupStageTeams()
let groupStageTeamsCount = groupStageTeams.count
if groupStageTeamsCount == 0 || groupStageTeamsCount != groupStageSpots() {
return ("à compléter", nil)
}
let cut : TeamRegistration.TeamRange? = isAnimation() ? nil : TeamRegistration.TeamRange(groupStageTeams.first, groupStageTeams.last)
let runningGroupStages = groupStages().filter({ $0.isRunning() })
if groupStagesAreOver() { return ("terminées", cut) }
if runningGroupStages.isEmpty {
let ongoingGroupStages = runningGroupStages.filter({ $0.hasStarted() && $0.hasEnded() == false })
if ongoingGroupStages.isEmpty == false {
return ("Poule" + ongoingGroupStages.count.pluralSuffix + " " + ongoingGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut)
}
return (groupStages().count.formatted() + " poule" + groupStages().count.pluralSuffix, cut)
} else {
return ("Poule" + runningGroupStages.count.pluralSuffix + " " + runningGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut)
}
}
func settingsDescriptionLocalizedLabel() -> String {
[courtCount.formatted() + " terrain\(courtCount.pluralSuffix)", entryFeeMessage].joined(separator: ", ")
}
func structureDescriptionLocalizedLabel() -> String {
let groupStageLabel: String? = groupStageCount > 0 ? groupStageCount.formatted() + " poule\(groupStageCount.pluralSuffix)" : nil
return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ")
}
fileprivate func _paymentMethodMessage() -> String? {
return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods
}
func updateRank(to newDate: Date?) async throws {
guard let newDate else { return }
rankSourceDate = newDate
if currentMonthData() == nil {
let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate)
let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate)
await MainActor.run {
let formatted: String = URL.importDateFormatter.string(from: newDate)
let monthData: MonthData = MonthData(monthKey: formatted)
monthData.maleUnrankedValue = lastRankMan
monthData.femaleUnrankedValue = lastRankWoman
do {
try DataStore.shared.monthData.addOrUpdate(instance: monthData)
} catch {
Logger.error(error)
}
}
}
let lastRankMan = currentMonthData()?.maleUnrankedValue
let lastRankWoman = currentMonthData()?.femaleUnrankedValue
let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate }
let sources = dataURLs.map { CSVParser(url: $0) }
try await unsortedPlayers().concurrentForEach { player in
try await player.updateRank(from: sources, lastRank: (player.sex == .female ? lastRankWoman : lastRankMan) ?? 0)
}
}
var entryFeeMessage: String {
if let entryFee {
let message: String = "Inscription: \(entryFee.formatted(.currency(code: "EUR"))) par joueur."
return [message, self._paymentMethodMessage()].compactMap { $0 }.joined(separator: "\n")
} else {
return "Inscription: gratuite."
}
}
func deleteAndBuildEverything() {
resetBracketPosition()
deleteStructure()
deleteGroupStages()
buildGroupStages()
buildBracket()
}
func buildBracket() {
guard rounds().isEmpty else { return }
let roundCount = RoundRule.numberOfRounds(forTeams: bracketTeamCount())
let rounds = (0..<roundCount).map { //index 0 is the final
return Round(tournament: id, index: $0, matchFormat: roundSmartMatchFormat($0))
}
do {
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds)
} catch {
Logger.error(error)
}
let matchCount = RoundRule.numberOfMatches(forTeams: bracketTeamCount())
let matches = (0..<matchCount).map { //0 is final match
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0)
let round = rounds[roundIndex]
return Match(round: round.id, index: $0, matchFormat: round.matchFormat, name: Match.setServerTitle(upperRound: round, matchIndex: RoundRule.matchIndexWithinRound(fromMatchIndex: $0)))
}
print(matches.map {
(RoundRule.roundName(fromMatchIndex: $0.index), RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index))
})
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
rounds.forEach { round in
round.buildLoserBracket()
}
}
func registrationIssues() -> Int {
let players : [PlayerRegistration] = unsortedPlayers()
let selectedTeams : [TeamRegistration] = selectedSortedTeams()
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) }
let duplicates : [PlayerRegistration] = duplicates(in: players)
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil })
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players)
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players)
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 })
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true)
let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil })
let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil })
return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count
}
func playersWithoutValidLicense(in players: [PlayerRegistration]) -> [PlayerRegistration] {
let licenseYearValidity = self.licenseYearValidity()
return players.filter { $0.hasInvalidLicence() }
}
func missingUnrankedValue() -> Bool {
return maleUnrankedValue == nil || femaleUnrankedValue == nil
}
}
extension Tournament {
static func newEmptyInstance() -> Tournament {
let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource
var _mostRecentDateAvailable: Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
let rankSourceDate = _mostRecentDateAvailable
let tournaments : [Tournament] = DataStore.shared.tournaments.filter { $0.endDate != nil && $0.isDeleted == false }
let tournamentLevel = TournamentLevel.mostUsed(inTournaments: tournaments)
let tournamentCategory = TournamentCategory.mostUsed(inTournaments: tournaments)
let federalTournamentAge = FederalTournamentAge.mostUsed(inTournaments: tournaments)
//creator: DataStore.shared.user?.id
return Tournament(groupStageSortMode: .snake, rankSourceDate: rankSourceDate, teamSorting: tournamentLevel.defaultTeamSortingType, federalCategory: tournamentCategory, federalLevelCategory: tournamentLevel, federalAgeCategory: federalTournamentAge)
}
static func fake() -> Tournament {
return Tournament(event: "Roland Garros", name: "Magic P100", startDate: Date(), endDate: Date(), creationDate: Date(), isPrivate: false, groupStageFormat: .nineGames, roundFormat: nil, loserRoundFormat: nil, groupStageSortMode: .snake, groupStageCount: 4, rankSourceDate: nil, dayDuration: 2, teamCount: 24, teamSorting: .rank, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .a45, closedRegistrationDate: nil, groupStageAdditionalQualified: 0, courtCount: 4, prioritizeClubMembers: false, qualifiedPerGroupStage: 2, teamsPerGroupStage: 4, entryFee: nil)
}
}

File diff suppressed because it is too large Load Diff

@ -1,38 +0,0 @@
//
// Array+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
extension Array where Element: Equatable {
/// Remove first collection element that is equal to the given `object` or `element`:
mutating func remove(elements: [Element]) {
elements.forEach {
if let index = firstIndex(of: $0) {
remove(at: index)
}
}
}
}
extension Array where Element: CustomStringConvertible {
func customJoined(separator: String, lastSeparator: String) -> String {
switch count {
case 0:
return ""
case 1:
return "\(self[0])"
case 2:
return "\(self[0]) \(lastSeparator) \(self[1])"
default:
let firstPart = dropLast().map { "\($0)" }.joined(separator: ", ")
let lastPart = "\(lastSeparator) \(last!)"
return "\(firstPart) \(lastPart)"
}
}
}

@ -0,0 +1,25 @@
//
// Badge+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
extension Badge {
func color() -> Color {
switch self {
case .checkmark:
.green
case .xmark:
.logoRed
case .custom(_, let color):
color
}
}
}

@ -1,26 +0,0 @@
//
// Calendar+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 28/03/2024.
//
import Foundation
extension Calendar {
func numberOfDaysBetween(_ from: Date?, and to: Date?) -> Int {
guard let from, let to else { return 0 }
let fromDate = startOfDay(for: from)
let toDate = startOfDay(for: to)
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
return numberOfDays.day! // <1>
}
func isSameDay(date1: Date?, date2: Date?) -> Bool {
guard let date1, let date2 else { return false }
return numberOfDaysBetween(date1, and: date2) == 0
}
}

@ -1,16 +1,17 @@
//
// User+Extensions.swift
// CustomUser+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 28/08/2024.
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension User {
extension CustomUser {
func currentPlayerData() -> ImportedPlayer? {
guard let licenceId else { return nil }
guard let licenceId = self.licenceId?.strippedLicense else { return nil }
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "license == %@", licenceId)

@ -1,180 +0,0 @@
//
// Date+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
enum TimeOfDay {
case morning
case noon
case afternoon
case evening
case night
var hello: String {
switch self {
case .morning, .noon, .afternoon:
return "Bonjour"
case .evening, .night:
return "Bonsoir"
}
}
var goodbye: String {
switch self {
case .morning, .noon, .afternoon:
return "Bonne journée"
case .evening, .night:
return "Bonne soirée"
}
}
}
extension Date {
func localizedDate() -> String {
self.formatted(.dateTime.weekday().day().month()) + " à " + self.formattedAsHourMinute()
}
func formattedAsHourMinute() -> String {
formatted(.dateTime.hour().minute())
}
func formattedAsDate() -> String {
formatted(.dateTime.weekday().day(.twoDigits).month().year())
}
var monthYearFormatted: String {
formatted(.dateTime.month(.wide).year(.defaultDigits))
}
var twoDigitsYearFormatted: String {
formatted(Date.FormatStyle(date: .numeric, time: .omitted).locale(Locale(identifier: "fr_FR")).year(.twoDigits))
}
var timeOfDay: TimeOfDay {
let hour = Calendar.current.component(.hour, from: self)
switch hour {
case 6..<12 : return .morning
case 12 : return .noon
case 13..<17 : return .afternoon
case 17..<22 : return .evening
default: return .night
}
}
}
extension Date {
static var firstDayOfWeek = Calendar.current.firstWeekday
static var capitalizedFirstLettersOfWeekdays: [String] {
let calendar = Calendar.current
// let weekdays = calendar.shortWeekdaySymbols
// return weekdays.map { weekday in
// guard let firstLetter = weekday.first else { return "" }
// return String(firstLetter).capitalized
// }
// Adjusted for the different weekday starts
var weekdays = calendar.veryShortStandaloneWeekdaySymbols
if firstDayOfWeek > 1 {
for _ in 1..<firstDayOfWeek {
if let first = weekdays.first {
weekdays.append(first)
weekdays.removeFirst()
}
}
}
return weekdays.map { $0.capitalized }
}
static var fullMonthNames: [String] {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
return (1...12).compactMap { month in
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM")
let date = Calendar.current.date(from: DateComponents(year: 2000, month: month, day: 1))
return date.map { dateFormatter.string(from: $0) }
}
}
var startOfMonth: Date {
Calendar.current.dateInterval(of: .month, for: self)!.start
}
var endOfMonth: Date {
let lastDay = Calendar.current.dateInterval(of: .month, for: self)!.end
return Calendar.current.date(byAdding: .day, value: -1, to: lastDay)!
}
var startOfPreviousMonth: Date {
let dayInPreviousMonth = Calendar.current.date(byAdding: .month, value: -1, to: self)!
return dayInPreviousMonth.startOfMonth
}
var numberOfDaysInMonth: Int {
Calendar.current.component(.day, from: endOfMonth)
}
// var sundayBeforeStart: Date {
// let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth)
// let numberFromPreviousMonth = startOfMonthWeekday - 1
// return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)!
// }
// New to accomodate for different start of week days
var firstWeekDayBeforeStart: Date {
let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth)
let numberFromPreviousMonth = startOfMonthWeekday - Self.firstDayOfWeek
return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)!
}
var calendarDisplayDays: [Date] {
var days: [Date] = []
// Current month days
for dayOffset in 0..<numberOfDaysInMonth {
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfMonth)
days.append(newDay!)
}
// previous month days
for dayOffset in 0..<startOfPreviousMonth.numberOfDaysInMonth {
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfPreviousMonth)
days.append(newDay!)
}
// Fixed to accomodate different weekday starts
return days.filter { $0 >= firstWeekDayBeforeStart && $0 <= endOfMonth }.sorted(by: <)
}
var monthInt: Int {
Calendar.current.component(.month, from: self)
}
var yearInt: Int {
Calendar.current.component(.year, from: self)
}
func endOfDay() -> Date {
let calendar = Calendar.current
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: self)!
}
}
extension Date {
func localizedTime() -> String {
self.formattedAsHourMinute()
}
func localizedDay() -> String {
self.formatted(.dateTime.weekday(.wide).day())
}
func localizedWeekDay() -> String {
self.formatted(.dateTime.weekday(.wide))
}
}

@ -1,25 +0,0 @@
//
// FixedWidthInteger+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
public extension FixedWidthInteger {
func ordinalFormattedSuffix() -> String {
switch self {
case 1: return "er"
default: return "ème"
}
}
func ordinalFormatted() -> String {
return self.formatted() + self.ordinalFormattedSuffix()
}
var pluralSuffix: String {
return self > 1 ? "s" : ""
}
}

@ -1,23 +0,0 @@
//
// Locale+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 03/04/2024.
//
import Foundation
extension Locale {
static func countries() -> [String] {
var countries: [String] = []
for countryCode in Locale.Region.isoRegions {
if let countryName = Locale.current.localizedString(forRegionCode: countryCode.identifier) {
countries.append(countryName)
}
}
return countries.sorted()
}
}

@ -2,7 +2,7 @@
// MonthData+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 27/08/2024.
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
@ -15,28 +15,34 @@ extension MonthData {
let fileURL = SourceFileManager.shared.allFiles(true).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
print("calculateCurrentUnrankedValues", fromDate.monthYearFormatted, fileURL?.path())
let fftImportingUncomplete = fileURL?.fftImportingUncomplete()
var fftImportingAnonymous = fileURL?.fftImportingAnonymous()
let fftImportingMaleUnrankValue = fileURL?.fftImportingMaleUnrankValue()
let femaleFileURL = SourceFileManager.shared.allFiles(false).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
let femaleFftImportingMaleUnrankValue = femaleFileURL?.fftImportingMaleUnrankValue()
let femaleFftImportingUncomplete = femaleFileURL?.fftImportingUncomplete()
let incompleteMode = fftImportingUncomplete != nil
let lastDataSourceMaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: true)
let lastDataSourceFemaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: false)
let anonymousCount = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
if fftImportingAnonymous == nil {
fftImportingAnonymous = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
}
let anonymousCount: Int? = fftImportingAnonymous
await MainActor.run {
let lastDataSource = URL.importDateFormatter.string(from: fromDate)
let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource)
currentMonthData._updateCreationDate()
currentMonthData.dataModelIdentifier = PersistenceController.getModelVersion()
currentMonthData.fileModelIdentifier = fileURL?.fileModelIdentifier()
currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0
currentMonthData.incompleteMode = incompleteMode
currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1
currentMonthData.femaleUnrankedValue = lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = lastDataSourceFemaleUnranked?.1
currentMonthData.femaleUnrankedValue = incompleteMode ? femaleFftImportingMaleUnrankValue : lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = incompleteMode ? femaleFftImportingUncomplete : lastDataSourceFemaleUnranked?.1
currentMonthData.anonymousCount = anonymousCount
do {
try DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
} catch {
Logger.error(error)
}
DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
}
}

@ -1,16 +0,0 @@
//
// NumberFormatter+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 27/03/2024.
//
import Foundation
extension NumberFormatter {
static var ordinal: NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter
}
}

@ -0,0 +1,230 @@
//
// PlayerRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension PlayerRegistration {
convenience init(importedPlayer: ImportedPlayer) {
self.init()
self.teamRegistration = ""
self.firstName = (importedPlayer.firstName ?? "").prefixTrimmed(50).capitalized
self.lastName = (importedPlayer.lastName ?? "").prefixTrimmed(50).uppercased()
self.licenceId = importedPlayer.license?.prefixTrimmed(50) ?? nil
self.rank = Int(importedPlayer.rank)
self.sex = importedPlayer.male ? .male : .female
self.tournamentPlayed = importedPlayer.tournamentPlayed
self.points = importedPlayer.getPoints()
self.clubName = importedPlayer.clubName?.prefixTrimmed(200)
self.clubCode = importedPlayer.clubCode?.replaceCharactersFromSet(characterSet: .whitespaces).prefixTrimmed(20)
self.ligueName = importedPlayer.ligueName?.prefixTrimmed(200)
self.assimilation = importedPlayer.assimilation?.prefixTrimmed(50)
self.source = .frenchFederation
self.birthdate = importedPlayer.birthYear?.prefixTrimmed(50)
}
convenience init?(federalData: [String], sex: Int, sexUnknown: Bool) {
self.init()
let _lastName = federalData[0].trimmed.uppercased()
let _firstName = federalData[1].trimmed.capitalized
if _lastName.isEmpty && _firstName.isEmpty { return nil }
lastName = _lastName.prefixTrimmed(50)
firstName = _firstName.prefixTrimmed(50)
birthdate = federalData[2].formattedAsBirthdate().prefixTrimmed(50)
licenceId = federalData[3].prefixTrimmed(50)
clubName = federalData[4].prefixTrimmed(200)
let stringRank = federalData[5]
if stringRank.isEmpty {
rank = nil
} else {
rank = Int(stringRank)
}
let _email = federalData[6]
if _email.isEmpty == false {
self.email = _email.prefixTrimmed(50)
}
let _phoneNumber = federalData[7]
if _phoneNumber.isEmpty == false {
self.phoneNumber = _phoneNumber.prefixTrimmed(50)
}
source = .beachPadel
if sexUnknown {
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) {
self.sex = .female
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) {
self.sex = .male
} else {
self.sex = nil
}
} else {
self.sex = PlayerSexType(rawValue: sex)
}
}
}
extension PlayerRegistration {
func hasHomonym() -> Bool {
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName)
fetchRequest.predicate = predicate
do {
let count = try federalContext.count(for: fetchRequest)
return count > 1
} catch {
}
return false
}
func updateRank(from sources: [CSVParser], lastRank: Int?) async throws {
#if DEBUG_TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if let dataFound = try await history(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else if let dataFound = try await historyFromName(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else {
rank = lastRank
}
}
func history(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG_TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func history()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let license = licenceId?.strippedLicense else {
return nil // Do NOT call historyFromName here, let updateRank handle it
}
let filteredSources = sources.filter { $0.maleData == isMalePlayer() }
return await withTaskGroup(of: Line?.self) { group in
for source in filteredSources {
group.addTask {
guard !Task.isCancelled else { return nil }
return try? await source.first { $0.rawValue.contains(";\(license);") }
}
}
for await result in group {
if let result {
group.cancelAll() // Stop other tasks as soon as we find a match
return result
}
}
return nil
}
}
func historyFromName(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func historyFromName()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let filteredSources = sources.filter { $0.maleData == isMalePlayer() }
let normalizedLastName = lastName.canonicalVersionWithPunctuation
let normalizedFirstName = firstName.canonicalVersionWithPunctuation
return await withTaskGroup(of: Line?.self) { group in
for source in filteredSources {
group.addTask {
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first {
let lineValue = $0.rawValue.canonicalVersionWithPunctuation
return lineValue.contains(";\(normalizedLastName);\(normalizedFirstName);")
}
}
}
for await result in group {
if let result {
group.cancelAll() // Stop other tasks as soon as we find a match
return result
}
}
return nil
}
}
}
extension PlayerRegistration: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
nil
}
func getFirstName() -> String {
firstName
}
func getLastName() -> String {
lastName
}
func getPoints() -> Double? {
self.points
}
func getRank() -> Int? {
rank
}
func isUnranked() -> Bool {
rank == nil
}
func formattedRank() -> String {
self.rankLabel()
}
func formattedLicense() -> String {
if let licenceId { return licenceId.computedLicense }
return "aucune licence"
}
var male: Bool {
isMalePlayer()
}
func getBirthYear() -> Int? {
nil
}
func getProgression() -> Int {
0
}
func getComputedRank() -> Int? {
computedRank
}
}

@ -0,0 +1,33 @@
//
// Round+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 30/04/2025.
//
import Foundation
import PadelClubData
extension Round {
func loserBracketTurns() -> [LoserRound] {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func loserBracketTurns()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
var rounds = [LoserRound]()
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
for index in 0..<roundCount {
let lr = LoserRound(roundIndex: roundCount - index - 1, turnIndex: index, upperBracketRound: self)
rounds.append(lr)
}
return rounds
}
}

@ -1,33 +0,0 @@
//
// Sequence+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
extension Sequence {
func pairs() -> AnySequence<(Element, Element)> {
AnySequence(zip(self, self.dropFirst()))
}
}
extension Sequence {
func concurrentForEach(
_ operation: @escaping (Element) async throws -> Void
) async throws {
// A task group automatically waits for all of its
// sub-tasks to complete, while also performing those
// tasks in parallel:
try await withThrowingTaskGroup(of: Void.self) { group in
for element in self {
group.addTask {
try await operation(element)
}
for try await _ in group {}
}
}
}
}

@ -0,0 +1,34 @@
//
// SourceFileManager+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import LeStorage
import PadelClubData
extension SourceFileManager {
func exportToCSV(_ prefix: String = "", players: [FederalPlayer], sourceFileType: SourceFile, date: Date) {
let lastDateString = URL.importDateFormatter.string(from: date)
let dateString = [prefix, "CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].filter({ $0.isEmpty == false }).joined(separator: "-") + "." + "csv"
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)!
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
var csvText : String = ""
for player in players {
csvText.append(player.exportToCSV() + "\n")
}
do {
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8)
print("CSV file exported successfully.")
} catch {
print("Error writing CSV file:", error)
Logger.error(error)
}
}
}

@ -0,0 +1,42 @@
//
// SpinDrawable+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension String: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
[self]
}
}
extension Match: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
let teams = teams()
if teams.count == 1, hideNames == false {
return teams.first!.segmentLabel(displayStyle, hideNames: hideNames)
} else {
return [roundTitle(), matchTitle(displayStyle)].compactMap { $0 }
}
}
}
extension TeamRegistration: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
var strings: [String] = []
let indexLabel = tournamentObject()?.labelIndexOf(team: self)
if let indexLabel {
strings.append(indexLabel)
if hideNames {
return strings
}
}
strings.append(contentsOf: self.players().map { $0.playerLabel(displayStyle) })
return strings
}
}

@ -1,151 +0,0 @@
//
// String+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
// MARK: - Trimming and stuff
extension String {
func trunc(length: Int, trailing: String = "") -> String {
return (self.count > length) ? self.prefix(length) + trailing : self
}
func isValidEmail() -> Bool {
let emailRegEx = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$"
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailPredicate.evaluate(with: self)
}
}
// MARK: - FFT License
extension String {
var computedLicense: String {
if let licenseKey {
return self + licenseKey
} else {
return self
}
}
var strippedLicense: String? {
var dropFirst = 0
if hasPrefix("0") {
dropFirst = 1
}
if let match = self.dropFirst(dropFirst).firstMatch(of: /[0-9]{6,8}/) {
let lic = String(self.dropFirst(dropFirst)[match.range.lowerBound..<match.range.upperBound])
return lic
} else {
return nil
}
}
var isLicenseNumber: Bool {
if let match = self.firstMatch(of: /[0-9]{6,8}[A-Z]/) {
let lic = String(self[match.range.lowerBound..<match.range.upperBound].dropLast(1))
let lastLetter = String(self[match.range.lowerBound..<match.range.upperBound].suffix(1))
if let lkey = lic.licenseKey {
return lkey == lastLetter
}
}
return false
}
var licenseKey: String? {
if let intValue = Int(self) {
var value = intValue
value -= 1
value = value % 23
let v = UnicodeScalar("A").value
let i = Int(v)
if let s = UnicodeScalar(i + value) {
var c = Character(s)
if c >= "I" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
if c >= "O" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
if c >= "Q" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
return String(c)
}
}
return nil
}
func licencesFound() -> [String] {
let matches = self.matches(of: /[1-9][0-9]{5,7}/)
return matches.map { String(self[$0.range]) }
}
}
// MARK: - FFT Source Importing
extension String {
enum RegexStatic {
static let mobileNumber = /^0[6-7]/
//static let mobileNumber = /^(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})$/
}
func isMobileNumber() -> Bool {
firstMatch(of: RegexStatic.mobileNumber) != nil
}
//april 04-2024 bug with accent characters / adobe / fft
mutating func replace(characters: [(Character, Character)]) {
for (targetChar, replacementChar) in characters {
self = String(self.map { $0 == targetChar ? replacementChar : $0 })
}
}
}
// MARK: - Player Names
extension StringProtocol {
var firstUppercased: String { prefix(1).uppercased() + dropFirst() }
var firstCapitalized: String { prefix(1).capitalized + dropFirst() }
}
// MARK: - todo clean up ??
extension LosslessStringConvertible {
var string: String { .init(self) }
}
extension String {
func createFile(_ withName: String = "temp", _ exportedFormat: ExportFormat = .rawText) -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(withName)
.appendingPathExtension(exportedFormat.suffix)
let string = self
try? FileManager.default.removeItem(at: url)
try? string.write(to: url, atomically: true, encoding: .utf8)
return url
}
}
extension String {
func toInt() -> Int? {
Int(self)
}
}

@ -0,0 +1,84 @@
//
// TeamRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
extension TeamRegistration {
func initialRoundColor() -> Color? {
if walkOut { return Color.logoRed }
if groupStagePosition != nil || wildCardGroupStage { return Color.blue }
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] {
return Color(uiColor: .init(fromHex: colorHex))
} else if wildCardBracket {
return Color.mint
} else {
return nil
}
}
func updateWeight(inTournamentCategory tournamentCategory: TournamentCategory) {
self.setWeight(from: self.players(), inTournamentCategory: tournamentCategory)
}
func updatePlayers(
_ players: Set<PlayerRegistration>,
inTournamentCategory tournamentCategory: TournamentCategory
) {
let previousPlayers = Set(unsortedPlayers())
players.forEach { player in
previousPlayers.forEach { oldPlayer in
if player.licenceId?.strippedLicense == oldPlayer.licenceId?.strippedLicense,
player.licenceId?.strippedLicense != nil
{
player.registeredOnline = oldPlayer.registeredOnline
if player.email?.canonicalVersion != oldPlayer.email?.canonicalVersion {
player.contactEmail = oldPlayer.email
} else {
player.contactEmail = oldPlayer.contactEmail
}
if areFrenchPhoneNumbersSimilar(player.phoneNumber, oldPlayer.phoneNumber) == false {
player.contactPhoneNumber = oldPlayer.phoneNumber
} else {
player.contactPhoneNumber = oldPlayer.contactPhoneNumber
}
player.contactName = oldPlayer.contactName
player.coach = oldPlayer.coach
player.tournamentPlayed = oldPlayer.tournamentPlayed
player.points = oldPlayer.points
player.captain = oldPlayer.captain
player.assimilation = oldPlayer.assimilation
player.ligueName = oldPlayer.ligueName
player.registrationStatus = oldPlayer.registrationStatus
player.timeToConfirm = oldPlayer.timeToConfirm
player.sex = oldPlayer.sex
player.paymentType = oldPlayer.paymentType
player.paymentId = oldPlayer.paymentId
player.clubMember = oldPlayer.clubMember
}
}
}
let playersToRemove = previousPlayers.subtracting(players)
self.tournamentStore?.playerRegistrations.delete(contentOfs: Array(playersToRemove))
setWeight(from: Array(players), inTournamentCategory: tournamentCategory)
players.forEach { player in
player.teamRegistration = id
}
// do {
// try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players)
// } catch {
// Logger.error(error)
// }
}
}

@ -0,0 +1,428 @@
//
// Tournament+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
import LeStorage
extension Tournament {
func addTeam(_ players: Set<PlayerRegistration>, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration {
let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date(), name: name)
team.setWeight(from: Array(players), inTournamentCategory: tournamentCategory)
players.forEach { player in
player.teamRegistration = team.id
}
if isAnimation() {
if team.weight == 0 {
team.weight = unsortedTeams().count
}
}
return team
}
func addWildCardIfNeeded(_ count: Int, _ type: MatchType) {
let currentCount = selectedSortedTeams().filter({
if type == .bracket {
return $0.wildCardBracket
} else {
return $0.wildCardGroupStage
}
}).count
if currentCount < count {
let _diff = count - currentCount
addWildCard(_diff, type)
}
}
func addEmptyTeamRegistration(_ count: Int) {
guard let tournamentStore = self.tournamentStore else { return }
let teams = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
return team
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
}
func addWildCard(_ count: Int, _ type: MatchType) {
let wcs = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
if type == .bracket {
team.wildCardBracket = true
} else {
team.wildCardGroupStage = true
}
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
team.weight += 200_000
return team
}
do {
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: wcs)
} catch {
Logger.error(error)
}
}
func teamsRanked() -> [TeamRegistration] {
let selected = selectedSortedTeams().filter({ $0.finalRanking != nil })
return selected.sorted(by: \.finalRanking!, order: .ascending)
}
func playersWithoutValidLicense(in players: [PlayerRegistration], isImported: Bool) -> [PlayerRegistration] {
let licenseYearValidity = self.licenseYearValidity()
return players.filter({ player in
if player.isImported() {
// Player is marked as imported: check if the license is valid
return !player.isValidLicenseNumber(year: licenseYearValidity)
} else {
// Player is not imported: validate license and handle `isImported` flag for non-imported players
let noLicenseId = player.licenceId == nil || player.licenceId?.isEmpty == true
let invalidFormattedLicense = player.formattedLicense().isLicenseNumber == false
// If global `isImported` is true, check license number as well
let invalidLicenseForImportedFlag = isImported && !player.isValidLicenseNumber(year: licenseYearValidity)
return noLicenseId || invalidFormattedLicense || invalidLicenseForImportedFlag
}
})
}
func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] {
players.filter({ $0.hasHomonym() })
}
func payIfNecessary() async throws {
if self.payment != nil { return }
if let payment = await Guard.main.paymentForNewTournament() {
self.payment = payment
DataStore.shared.tournaments.addOrUpdate(instance: self)
return
}
throw PaymentError.cantPayTournament
}
func cutLabelColor(index: Int?, teamCount: Int?) -> Color {
guard let index else { return Color.grayNotUniversal }
let _teamCount = teamCount ?? selectedSortedTeams().count
let groupStageCut = groupStageCut()
let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut)
if index < bracketCut {
return Color.mint
} else if index - bracketCut < groupStageCut && _teamCount > 0 {
return Color.indigo
} else {
return Color.grayNotUniversal
}
}
func isPlayerAgeInadequate(player: PlayerHolder) -> Bool {
guard let computedAge = player.computedAge else { return false }
if federalTournamentAge.isAgeValid(age: computedAge) == false {
return true
} else {
return false
}
}
func isPlayerRankInadequate(player: PlayerHolder) -> Bool {
guard let rank = player.getRank() else { return false }
let _rank = player.male ? rank : rank + addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0)
if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge, seasonYear: startDate.seasonYear()) {
return true
} else {
return false
}
}
func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerRankInadequate(player: player)
}
}
func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerAgeInadequate(player: player)
}
}
func importTeams(_ teams: [FileImportManager.TeamHolder]) {
var teamsToImport = [TeamRegistration]()
let players = players().filter { $0.licenceId != nil }
teams.forEach { team in
if let previousTeam = team.previousTeam {
previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory)
teamsToImport.append(previousTeam)
} else {
var registrationDate = team.registrationDate
if let previousPlayer = players.first(where: { player in
let ids = team.players.compactMap({ $0.licenceId })
return ids.contains(player.licenceId!)
}), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate {
registrationDate = previousTeamRegistrationDate
}
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name)
if isAnimation() {
if newTeam.weight == 0 {
newTeam.weight = team.index(in: teams) ?? 0
}
}
teamsToImport.append(newTeam)
}
}
if let tournamentStore = self.tournamentStore {
tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport)
let playersToImport = teams.flatMap { $0.players }
tournamentStore.playerRegistrations.addOrUpdate(contentOfs: playersToImport)
}
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty {
setGroupStage(randomize: groupStageSortMode == .random)
}
}
func registrationIssues(selectedTeams: [TeamRegistration]) async -> Int {
let players : [PlayerRegistration] = selectedTeams.flatMap { $0.players() }
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) }
let duplicates : [PlayerRegistration] = duplicates(in: players)
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil })
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players)
let homonyms = homonyms(in: players)
let ageInadequatePlayers = ageInadequatePlayers(in: players)
let isImported = players.anySatisfy({ $0.isImported() })
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players, isImported: isImported)
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 })
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true)
let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil })
let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil })
return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count + ageInadequatePlayers.count + homonyms.count
}
func updateRank(to newDate: Date?, forceRefreshLockWeight: Bool, providedSources: [CSVParser]?) async throws {
refreshRanking = true
#if DEBUG_TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let newDate else { return }
rankSourceDate = newDate
// Fetch current month data only once
var monthData = currentMonthData()
if monthData == nil {
async let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate)
async let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate)
let formatted = URL.importDateFormatter.string(from: newDate)
let newMonthData = MonthData(monthKey: formatted)
newMonthData.maleUnrankedValue = await lastRankMan
newMonthData.femaleUnrankedValue = await lastRankWoman
DataStore.shared.monthData.addOrUpdate(instance: newMonthData)
monthData = newMonthData
}
let lastRankMan = monthData?.maleUnrankedValue
let lastRankWoman = monthData?.femaleUnrankedValue
var chunkedParsers: [CSVParser] = []
if let providedSources {
chunkedParsers = providedSources
} else {
// Fetch only the required files
let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate }
guard !dataURLs.isEmpty else { return } // Early return if no files found
let sources = dataURLs.map { CSVParser(url: $0) }
chunkedParsers = try await chunkAllSources(sources: sources, size: 10000)
}
let players = unsortedPlayers()
for player in players {
let lastRank = (player.sex == .female) ? lastRankWoman : lastRankMan
try await player.updateRank(from: chunkedParsers, lastRank: lastRank)
player.setComputedRank(in: self)
}
if providedSources == nil {
try chunkedParsers.forEach { chunk in
try FileManager.default.removeItem(at: chunk.url)
}
}
tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players)
let unsortedTeams = unsortedTeams()
unsortedTeams.forEach { team in
team.setWeight(from: team.players(), inTournamentCategory: tournamentCategory)
if forceRefreshLockWeight {
team.lockedWeight = team.weight
}
}
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams)
refreshRanking = false
}
}
extension Tournament {
static func newEmptyInstance() -> Tournament {
let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource
var _mostRecentDateAvailable: Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
let rankSourceDate = _mostRecentDateAvailable
return Tournament(rankSourceDate: rankSourceDate, currencyCode: Locale.defaultCurrency())
}
}
extension Tournament: FederalTournamentHolder {
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String {
if isAnimation() {
if let name {
return name.trunc(length: DeviceHelper.charLength())
} else if build.age == .unlisted, build.category == .unlisted {
return build.level.localizedLevelLabel(.title)
} else {
return build.level.localizedLevelLabel(displayStyle)
}
}
return build.level.localizedLevelLabel(displayStyle)
}
var codeClub: String? {
club()?.code
}
var holderId: String { id }
func clubLabel() -> String {
locationLabel()
}
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
if isAnimation() {
if displayAgeAndCategory(forBuild: build) == false {
return [build.category.localizedCategoryLabel(ageCategory: build.age), build.age.localizedFederalAgeLabel()].filter({ $0.isEmpty == false }).joined(separator: " ")
} else if name != nil {
return build.level.localizedLevelLabel(.title)
} else {
return ""
}
} else {
return subtitle()
}
}
var tournaments: [any TournamentBuildHolder] {
[
self
]
}
var dayPeriod: DayPeriod {
let day = startDate.get(.weekday)
switch day {
case 2...6:
return .week
default:
return .weekend
}
}
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool {
if isAnimation() {
if let name, name.count < DeviceHelper.maxCharacter() {
return true
} else if build.age == .unlisted, build.category == .unlisted {
return true
} else {
return DeviceHelper.isBigScreen()
}
}
return true
}
}
extension Tournament: TournamentBuildHolder {
public func buildHolderTitle(_ displayStyle: DisplayStyle) -> String {
tournamentTitle(.short)
}
public var category: TournamentCategory {
tournamentCategory
}
public var level: TournamentLevel {
tournamentLevel
}
public var age: FederalTournamentAge {
federalTournamentAge
}
}
// MARK: - UI extensions
extension Tournament {
public var shouldShowPaymentInfo: Bool {
if self.payment != nil {
return false
}
switch self.state() {
case .initial, .build, .running:
return true
default:
return false
}
}
}
//extension Tournament {
// func deadline(for type: TournamentDeadlineType) -> Date? {
// guard [.p500, .p1000, .p1500, .p2000].contains(tournamentLevel) else { return nil }
//
// let daysOffset = type.daysOffset(level: tournamentLevel)
// if let date = Calendar.current.date(byAdding: .day, value: daysOffset, to: startDate) {
// let startOfDay = Calendar.current.startOfDay(for: date)
// return Calendar.current.date(byAdding: type.timeOffset, to: startOfDay)
// }
// return nil
// }
//}

@ -0,0 +1,22 @@
//
// View+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 29/09/2025.
//
import SwiftUI
extension View {
/// Runs a transform only on iOS 26+, otherwise returns self
@ViewBuilder
func ifAvailableiOS26<Content: View>(
@ViewBuilder transform: (Self) -> Content
) -> some View {
if #available(iOS 26.0, *) {
transform(self)
} else {
self
}
}
}

@ -1,4 +1,7 @@
<ul class="round">
<li class="spacer">&nbsp;{{roundLabel}}</li>
<li class="spacer" style="transform: translateY(-20px);">
&nbsp;{{roundLabel}}
<div>{{formatLabel}}</div>
</li>
{{match-template}}
</ul>

@ -82,6 +82,7 @@ body{
<caption>
<h2>{{bracketTitle}}</h2>
<h3>{{bracketStartDate}}</h3>
<h3>{{formatLabel}}</h3>
</caption>
<tr>
<th scope="col" style="visibility:hidden"></th>

@ -1,8 +1,14 @@
<li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}">
<li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}; position: relative;">
{{entrantOne}}
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionTop}}</div>
</li>
<li class="game game-spacer" style="visibility:{{hidden}}"><div class="multiline">{{matchDescription}}</div></li>
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}">
<li class="game game-spacer" style="visibility:{{hidden}}">
<div class="center-match-overlay" style="visibility:{{hidden}};">{{centerMatchText}}</div>
</li>
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}; position: relative;">
<div style="transform: translateY(-100%);">
{{entrantTwo}}
</div>
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionBottom}}</div>
</li>
<li class="spacer">&nbsp;</li>

@ -1,3 +1,4 @@
<div class="player">{{teamIndex}}</div>
<div class="player">{{playerOne}}<span>{{weightOne}}</span></div>
<div class="player">{{playerTwo}}<span>{{weightTwo}}</span></div>

@ -9,6 +9,7 @@
flex-direction:row;
padding: 1%;
}
.round{
display:flex;
flex-direction:column;
@ -27,7 +28,7 @@
.round .spacer{ flex-grow:1;
font-size:24px;
text-align: center;
color: #bbb;
color: #000000;
font-style:italic;
}
.round .spacer:first-child,
@ -65,7 +66,7 @@
li.game-spacer{
border-right:2px solid #4f7a38;
min-height:156px;
min-height:{{minHeight}}px;
text-align: right;
display : flex;
justify-content: center;
@ -91,11 +92,40 @@
overflow: hidden;
text-overflow: ellipsis;
}
.game {
/* Ensure the game container is a positioning context for the overlay */
position: relative;
/* Add any other existing styles for your game list items */
}
.match-description-overlay {
/* Position the overlay directly on top of the game item */
position: absolute;
top: 0;
left: 0;
transform: translateY(100%);
width: 100%;
height: 100%;
display: flex; /* Enable flexbox for centering */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically (if needed) */
font-size: 1em; /* Optional: Adjust font size */
/* Add any other desired styling for the overlay */
}
.center-match-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.8em;
white-space: nowrap; /* Prevents text from wrapping */
}
</style>
</head>
<body>
<h1>{{tournamentTitle}}</h1>
<h3 style="visibility:{{titleHidden}}">{{tournamentTitle}} - {{tournamentStartDate}}</h3>
<main id="tournament">
{{brackets}}
</main>

@ -33,7 +33,5 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>

@ -0,0 +1,60 @@
//
// WaitingListView.swift
// PadelClub
//
// Created by razmig on 26/02/2025.
//
import SwiftUI
struct WaitingListView: View {
@Environment(Tournament.self) var tournament: Tournament
let teamCount: Int
@ViewBuilder
var body: some View {
Text("Attention, l'inscription en ligne est activée et vous avez des équipes inscrites en ligne, en modifiant la structure ces équipes seront intégrées ou retirées de votre sélection d'équipes. Pour l'instant Padel Club ne saura pas les prévenir automatiquement, vous devrez les contacter via l'écran de gestion des inscriptions.")
.foregroundStyle(.logoRed)
let selection = tournament.selectedSortedTeams()
if teamCount > tournament.teamCount {
Section {
let teams = tournament.waitingListSortedTeams(selectedSortedTeams: selection)
.prefix(teamCount - tournament.teamCount)
.filter { $0.hasRegisteredOnline() }
ForEach(teams) { team in
NavigationLink {
EditingTeamView(team: team)
.environment(tournament)
} label: {
TeamRowView(team: team)
}
}
} header: {
Text("Équipes entrantes dans la sélection")
} footer: {
Text("Équipes inscrites en ligne à prévenir rentrant dans votre liste")
}
}
if teamCount < tournament.teamCount {
Section {
let teams = selection.suffix(tournament.teamCount - teamCount)
.filter { $0.hasRegisteredOnline() }
ForEach(teams) { team in
NavigationLink {
EditingTeamView(team: team)
.environment(tournament)
} label: {
TeamRowView(team: team)
}
}
} header: {
Text("Équipes sortantes de la sélection")
} footer: {
Text("Équipes inscrites en ligne à prévenir retirées de votre liste")
}
}
}
}

@ -8,6 +8,7 @@
import SwiftUI
import LeStorage
import TipKit
import PadelClubData
@main
struct PadelClubApp: App {
@ -17,6 +18,10 @@ struct PadelClubApp: App {
@StateObject var dataStore = DataStore.shared
@State private var registrationError: RegistrationError? = nil
@State private var importObserverViewModel = ImportObserver()
@State private var showDisconnectionAlert: Bool = false
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var requiredVersion: String? = nil
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@ -48,12 +53,25 @@ struct PadelClubApp: App {
let dictionary = Bundle.main.infoDictionary!
let version = dictionary["CFBundleShortVersionString"] as! String
let build = dictionary["CFBundleVersion"] as! String
#if DEBUG
return "\(version) (\(build)) Debug"
#elseif TESTFLIGHT
return "\(version) (\(build)) TestFlight"
#elseif PRODTEST
return "\(version) (\(build)) ProdTest"
#else
return "\(version) (\(build))"
#endif
}
var body: some Scene {
WindowGroup {
if let requiredVersion {
DownloadNewVersionView(version: requiredVersion)
} else {
MainView()
.environment(\.horizontalSizeClass, .compact)
.alert(isPresented: presentError, error: registrationError) {
Button("Contactez-nous") {
_openMail()
@ -74,9 +92,28 @@ struct PadelClubApp: App {
.environment(navigationViewModel)
.accentColor(.master)
.onAppear {
self._checkVersion()
if ManualPatcher.patchIfPossible(.disconnect) == true {
self.showDisconnectionAlert = true
}
#if DEBUG
print("Running in Debug mode")
#elseif TESTFLIGHT
print("Running in TestFlight mode")
#elseif PRODTEST
print("Running in ProdTest mode")
#else
print("Running in Release mode")
#endif
print(URLs.main.url)
networkMonitor.checkConnection()
self._onAppear()
print(PersistenceController.getModelVersion())
}
.alert(isPresented: self.$showDisconnectionAlert, content: {
Alert(title: Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données."))
})
.task {
// try? Tips.resetDatastore()
@ -89,6 +126,35 @@ struct PadelClubApp: App {
.environment(\.managedObjectContext, persistenceController.localContainer.viewContext)
}
}
}
fileprivate func _checkVersion() {
Task.detached(priority: .high) {
if let requiredVersion = await self._retrieveRequiredVersion() {
let cleanedRequired = requiredVersion.replacingOccurrences(of: "\n", with: "")
Logger.log(">>> REQUIRED VERSION = \(requiredVersion)")
if let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
await MainActor.run {
if VersionComparator.compare(cleanedRequired, currentVersion) == 1 {
self.requiredVersion = cleanedRequired
}
}
}
}
}
}
fileprivate func _retrieveRequiredVersion() async -> String? {
let requiredVersionURL = URLs.main.extend(path: "static/misc/required-version.txt")
do {
let (data, _) = try await URLSession.shared.data(from: requiredVersionURL)
return String(data: data, encoding: .utf8)
} catch {
Logger.log("Error fetching required version: \(error)")
return nil
}
}
private func _handleIncomingURL(_ url: URL) {
// Parse the URL
@ -127,11 +193,11 @@ struct PadelClubApp: App {
navigationViewModel.selectedTab = .umpire
}
if navigationViewModel.umpirePath.isEmpty {
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
} else if navigationViewModel.umpirePath.last! != .login {
navigationViewModel.umpirePath.removeAll()
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
if navigationViewModel.accountPath.isEmpty {
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
} else if navigationViewModel.accountPath.last! != .login {
navigationViewModel.accountPath.removeAll()
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
}
}
}.resume()
@ -153,3 +219,59 @@ struct PadelClubApp: App {
}
}
}
struct DownloadNewVersionView: View {
var version: String
var body: some View {
VStack {
// AngledStripesBackground()
Spacer()
VStack(spacing: 20.0) {
Text("Veuillez télécharger la nouvelle version de Padel Club pour continuer à vous servir de l'app !")
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding()
.background(.logoRed)
.clipShape(.buttonBorder)
// .padding(32.0)
VStack(alignment: .center, spacing: 0.0
) {
Text("Version \(self.version)")
.fontWeight(.bold)
Image(systemName: "square.and.arrow.down").font(.title)
}.padding().background(.logoYellow)
.clipShape(.buttonBorder)
}
.frame(maxWidth: .infinity)
.foregroundStyle(.logoBackground)
.fontWeight(.medium)
.multilineTextAlignment(.center)
.padding(.horizontal, 36.0)
Image("logo").padding(.vertical, 50.0)
Spacer()
}
.background(.logoBackground)
.onTapGesture {
UIApplication.shared.open(URLs.appStore.url)
}
}
}
struct DisconnectionAlertView: View {
var body: some View {
Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données.").multilineTextAlignment(.center).padding()
}
}
#Preview {
DownloadNewVersionView(version: "1.2")
}

Binary file not shown.

@ -1,11 +1,36 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "2055C391",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "14.0",
"displayPrice" : "129.0",
"familyShareable" : false,
"internalID" : "6751947241",
"localizations" : [
{
"description" : "Achetez 10 tournois",
"displayName" : "Pack de 10 tournois",
"locale" : "fr"
}
],
"productID" : "app.padelclub.tournament.unit.10",
"referenceName" : "Pack de 10 tournois",
"type" : "Consumable"
},
{
"displayPrice" : "17.0",
"familyShareable" : false,
"internalID" : "6484163993",
"localizations" : [
@ -22,57 +47,53 @@
],
"settings" : {
"_applicationInternalID" : "6484163558",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_compatibilityTimeRate" : {
"3" : 6
},
"_developerTeamID" : "BQ3Y44M3Q6",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 735034894.72550702,
"_locale" : "en_US",
"_storefront" : "USA",
"_lastSynchronizedDate" : 779705033.96878397,
"_locale" : "fr",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "FRA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
@ -89,7 +110,15 @@
"subscriptions" : [
{
"adHocOffers" : [
{
"displayPrice" : "45.0",
"internalID" : "1A02CDB5",
"numberOfPeriods" : 12,
"offerID" : "PRICE50",
"paymentMode" : "payAsYouGo",
"referenceName" : "ancien prix 50",
"subscriptionPeriod" : "P1M"
}
],
"codeOffers" : [
@ -110,7 +139,10 @@
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Five",
"subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription"
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
@ -135,13 +167,16 @@
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Unlimited",
"subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription"
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 3,
"major" : 4,
"minor" : 0
}
}

@ -1,224 +0,0 @@
//
// CloudConvert.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 14/09/2023.
//
import Foundation
class CloudConvert {
enum CloudConvertionError: LocalizedError {
case unknownError
case serviceError(ErrorResponse)
case urlNotFound(String)
var errorDescription: String? {
switch self {
case .unknownError:
return "Erreur"
case .serviceError(let errorResponse):
return errorResponse.error
case .urlNotFound(let url):
return "L'URL [\(url)] n'est pas valide"
}
}
}
static let manager = CloudConvert()
func uploadFile(_ url: URL) async throws -> String {
let taskResponse = try await createJob(url)
let uploadResponse = try await uploadFile(taskResponse, url: url)
var fileReady = false
while fileReady == false {
try await Task.sleep(nanoseconds: 3_000_000_000)
let progressResponse = try await checkFile(id: uploadResponse.data.id)
if progressResponse.data.step == "finish" && progressResponse.data.stepPercent == 100 {
fileReady = true
print("progressResponse.data.minutes", progressResponse.data.minutes)
}
}
let convertedFile = try await downloadConvertedFile(id: uploadResponse.data.id)
return convertedFile
}
func createJob(_ url: URL) async throws -> TaskResponse {
guard let taskURL = URL(string: "https://api.convertio.co/convert") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert")
}
var request: URLRequest = URLRequest(url: taskURL)
let parameters = """
{"apikey":"d97cf13ef6d163e5e386c381fc8d256f","input":"upload","file":"","filename":"","outputformat":"csv","options":""}
"""
let postData = parameters.data(using: .utf8)
request.httpMethod = "POST"
request.httpBody = postData
let task = try await URLSession.shared.data(for: request)
//print("tried: \(request.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(TaskResponse.self, from: task.0)
}
func uploadFile(_ response: TaskResponse, url: URL) async throws -> UploadResponse {
guard let uploadTaskURL = URL(string: "https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)")
}
var uploadRequest: URLRequest = URLRequest(url: uploadTaskURL)
uploadRequest.httpMethod = "PUT"
let uploadTask = try await URLSession.shared.upload(for: uploadRequest, fromFile: url)
//print("tried: \(uploadRequest.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: uploadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(UploadResponse.self, from: uploadTask.0)
}
func checkFile(id: String) async throws -> ProgressResponse {
guard let taskURL = URL(string: "https://api.convertio.co/convert/\(id)/status") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/status")
}
var request: URLRequest = URLRequest(url: taskURL)
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
//print("tried: \(request.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(ProgressResponse.self, from: task.0)
}
func downloadConvertedFile(id: String) async throws -> String {
// try await Task.sleep(nanoseconds: 3_000_000_000)
guard let downloadTaskURL = URL(string: "https://api.convertio.co/convert/\(id)/dl/base64") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/dl/base64")
}
var downloadRequest: URLRequest = URLRequest(url: downloadTaskURL)
downloadRequest.httpMethod = "GET"
let downloadTask = try await URLSession.shared.data(for: downloadRequest)
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: downloadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
//print("tried: \(downloadRequest.url)")
let dataResponse = try JSONDecoder().decode(DataResponse.self, from: downloadTask.0)
if let decodedData = Data(base64Encoded: dataResponse.data.content), let string = String(data: decodedData, encoding: .utf8) {
return string
}
throw CloudConvertionError.unknownError
}
}
// MARK: - DataResponse
struct DataResponse: Decodable {
let code: Int
let status: String
let data: DataDownloadClass
}
// MARK: - DataClass
struct DataDownloadClass: Decodable {
let id, encode, content: String
}
// MARK: - ErrorResponse
struct ErrorResponse: Decodable {
let code: Int
let status, error: String
}
// MARK: - TaskResponse
struct TaskResponse: Decodable {
let code: Int
let status: String
let data: DataClass
}
// MARK: - DataClass
struct DataClass: Decodable {
let id: String
}
// MARK: - ProgressResponse
struct ProgressResponse: Decodable {
let code: Int
let status: String
let data: ProgressDataClass
}
// MARK: - DataClass
struct ProgressDataClass: Decodable {
let id, step: String
let stepPercent: Int
let minutes: String
enum CodingKeys: String, CodingKey {
case id, step
case stepPercent = "step_percent"
case minutes
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
step = try container.decode(String.self, forKey: .step)
minutes = try container.decode(String.self, forKey: .minutes)
if let value = try? container.decode(String.self, forKey: .stepPercent) {
print(value)
stepPercent = Int(value) ?? 0
} else {
stepPercent = try container.decode(Int.self, forKey: .stepPercent)
}
}
}
// MARK: - Output
struct Output: Decodable {
let url: String
let size: String
}
// MARK: - UploadResponse
struct UploadResponse: Decodable {
let code: Int
let status: String
let data: UploadDataClass
}
// MARK: - DataClass
struct UploadDataClass: Decodable {
let id, file: String
let size: Int
}
extension URL {
var encodedLastPathComponent: String {
if #available(iOS 17.0, *) {
lastPathComponent
} else {
lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? lastPathComponent
}
}
}

@ -1,205 +0,0 @@
//
// ContactManager.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 19/09/2023.
//
import Foundation
import SwiftUI
import MessageUI
import LeStorage
enum ContactManagerError: LocalizedError {
case mailFailed
case mailNotSent //no network no error
case messageFailed
case messageNotSent //no network no error
}
enum ContactType: Identifiable {
case mail(date: Date?, recipients: [String]?, bccRecipients: [String]?, body: String?, subject: String?, tournamentBuild: TournamentBuild?)
case message(date: Date?, recipients: [String]?, body: String?, tournamentBuild: TournamentBuild?)
var id: Int {
switch self {
case .message: return 0
case .mail: return 1
}
}
}
extension ContactType {
static let defaultCustomMessage: String = "Il est conseillé de vous présenter 10 minutes avant de jouer.\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire."
static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces."
static func callingCustomMessage(source: String? = nil, tournament: Tournament?, startDate: Date?, roundLabel: String) -> String {
let tournamentCustomMessage = source ?? DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage
let clubName = tournament?.clubName ?? ""
var text = tournamentCustomMessage
let date = startDate ?? tournament?.startDate ?? Date()
if let tournament {
text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.short))
text = text.replacingOccurrences(of: "#prix", with: tournament.entryFeeMessage)
}
text = text.replacingOccurrences(of: "#club", with: clubName)
text = text.replacingOccurrences(of: "#manche", with: roundLabel.lowercased())
text = text.replacingOccurrences(of: "#jour", with: "\(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide)))")
text = text.replacingOccurrences(of: "#horaire", with: "\(date.formatted(Date.FormatStyle().hour().minute()))")
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature()
text = text.replacingOccurrences(of: "#signature", with: signature)
return text
}
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, reSummon: Bool = false) -> String {
let useFullCustomMessage = DataStore.shared.user.summonsUseFullCustomMessage
if useFullCustomMessage {
return callingCustomMessage(tournament: tournament, startDate: startDate, roundLabel: roundLabel)
}
let date = startDate ?? tournament?.startDate ?? Date()
let clubName = tournament?.clubName ?? ""
let message = DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature()
let localizedCalled = "convoqué" + (tournament?.tournamentCategory == .women ? "e" : "") + "s"
var entryFeeMessage: String? {
(DataStore.shared.user.summonsDisplayEntryFee) ? tournament?.entryFeeMessage : nil
}
var computedMessage: String {
[entryFeeMessage, message].compacted().map { $0.trimmed }.joined(separator: "\n\n")
}
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes"
if let tournament {
return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)"
} else {
return "Bonjour,\n\n\(intro) \(localizedCalled) \(roundLabel) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire !\n\n\(signature)"
}
}
}
struct MessageComposeView: UIViewControllerRepresentable {
typealias Completion = (_ result: MessageComposeResult) -> Void
static var canSendText: Bool { MFMessageComposeViewController.canSendText() }
let recipients: [String]?
let body: String?
let completion: Completion?
func makeUIViewController(context: Context) -> UIViewController {
guard Self.canSendText else {
let errorView = ContentUnavailableView("Aucun compte de messagerie", systemImage: "xmark", description: Text("Aucun compte de messagerie n'est configuré sur cet appareil."))
return UIHostingController(rootView: errorView)
}
let controller = MFMessageComposeViewController()
controller.messageComposeDelegate = context.coordinator
controller.recipients = recipients
controller.body = body
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: self.completion)
}
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate {
private let completion: Completion?
public init(completion: Completion?) {
self.completion = completion
}
public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) {
controller.dismiss(animated: true, completion: {
self.completion?(result)
})
}
}
}
struct MailComposeView: UIViewControllerRepresentable {
typealias Completion = (_ result: MFMailComposeResult) -> Void
static var canSendMail: Bool {
if let mailURL = URL(string: "mailto:?to=jap@padelclub.com") {
let mailConfigured = UIApplication.shared.canOpenURL(mailURL)
return mailConfigured && MFMailComposeViewController.canSendMail()
} else {
return MFMailComposeViewController.canSendMail()
}
}
let recipients: [String]?
let bccRecipients: [String]?
let body: String?
let subject: String?
var attachmentURL: URL?
let completion: Completion?
func makeUIViewController(context: Context) -> UIViewController {
guard Self.canSendMail else {
let errorView = ContentUnavailableView("Aucun compte mail", systemImage: "xmark", description: Text("Aucun compte mail n'est configuré sur cet appareil."))
return UIHostingController(rootView: errorView)
}
let controller = MFMailComposeViewController()
controller.mailComposeDelegate = context.coordinator
controller.setToRecipients(recipients)
controller.setBccRecipients(bccRecipients)
if let attachmentURL {
do {
let attachmentData = try Data(contentsOf: attachmentURL)
controller.addAttachmentData(attachmentData, mimeType: "application/zip", fileName: "backup.zip")
} catch {
print("Could not attach file: \(error)")
}
}
if let body {
controller.setMessageBody(body, isHTML: false)
}
if let subject {
controller.setSubject(subject)
}
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: self.completion)
}
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
private let completion: Completion?
public init(completion: Completion?) {
self.completion = completion
}
public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true, completion: {
self.completion?(result)
})
}
}
}

@ -1,23 +0,0 @@
//
// DisplayContext.swift
// PadelClub
//
// Created by Razmig Sarkissian on 20/03/2024.
//
import Foundation
enum DisplayContext {
case addition
case edition
case lockedForEditing
case selection
}
enum MatchViewStyle {
case standardStyle // vue normal
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche
case feedStyle // vue programmation
case plainStyle // vue detail
case tournamentResultStyle //vue resultat tournoi
}

@ -1,37 +0,0 @@
//
// ExportFormat.swift
// PadelClub
//
// Created by Razmig Sarkissian on 19/07/2024.
//
import Foundation
enum ExportFormat: Int, Identifiable, CaseIterable {
var id: Int { self.rawValue }
case rawText
case csv
var suffix: String {
switch self {
case .rawText:
return "txt"
case .csv:
return "csv"
}
}
func separator() -> String {
switch self {
case .rawText:
return " "
case .csv:
return ";"
}
}
func newLineSeparator(_ count: Int = 1) -> String {
return Array(repeating: "\n", count: count).joined()
}
}

@ -8,6 +8,7 @@
import Foundation
import LeStorage
import SwiftUI
import PadelClubData
enum FileImportManagerError: LocalizedError {
case unknownFormat
@ -28,9 +29,6 @@ class ImportObserver {
func currentlyImportingLabel() -> String {
guard let currentImportDate else { return "import en cours" }
if URL.importDateFormatter.string(from: currentImportDate) == "07-2024" {
return "consolidation des données"
}
return "import " + currentImportDate.monthYearFormatted
}
@ -44,29 +42,36 @@ class ImportObserver {
class FileImportManager {
static let shared = FileImportManager()
func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) {
let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")]
var playersLeft = players
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach({ url in
if playersLeft.isEmpty == false {
var playersLeft = Dictionary(uniqueKeysWithValues: players.map { ($0.license, $0) })
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach { url in
if playersLeft.isEmpty { return }
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
playersLeft.forEach { importedPlayer in
if let federalPlayer = federalPlayers.first(where: { $0.license == importedPlayer.license }) {
let federalPlayersDict = Dictionary(uniqueKeysWithValues: federalPlayers.map { ($0.license, $0) })
for (license, importedPlayer) in playersLeft {
guard let federalPlayer = federalPlayersDict[license] else { continue }
var lastName = federalPlayer.lastName
lastName.replace(characters: replacementsCharacters)
var firstName = federalPlayer.firstName
lastName.replace(characters: replacementsCharacters)
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
playersLeft.removeValue(forKey: license) // Remove processed player
}
}
playersLeft.removeAll(where: { $0.lastName.isEmpty == false })
}
})
players = Array(playersLeft.values)
}
func foundInWomenData(license: String?) -> Bool {
@ -127,14 +132,16 @@ class FileImportManager {
let weight: Int
let tournamentCategory: TournamentCategory
let tournamentAgeCategory: FederalTournamentAge
let tournamentLevel: TournamentLevel
let previousTeam: TeamRegistration?
var registrationDate: Date? = nil
var name: String? = nil
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, tournamentLevel: TournamentLevel, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
self.players = Set(players)
self.tournamentCategory = tournamentCategory
self.tournamentAgeCategory = tournamentAgeCategory
self.tournamentLevel = tournamentLevel
self.name = name
self.previousTeam = previousTeam
if players.count < 2 {
@ -147,7 +154,7 @@ class FileImportManager {
}
let significantPlayerCount = 2
let pl = players.prefix(significantPlayerCount).map { $0.computedRank }
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 70_000 : 10_000) }).prefix(significantPlayerCount)
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 92_327 : 10_000) }).prefix(significantPlayerCount)
self.weight = pl.reduce(0,+) + missingPl.reduce(0,+)
} else {
self.weight = players.map { $0.computedRank }.reduce(0,+)
@ -178,7 +185,7 @@ class FileImportManager {
static let FFT_ASSIMILATION_WOMAN_IN_MAN = "A calculer selon la pondération en vigueur"
func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool) async throws -> [TeamHolder] {
func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool, chunkByParameter: Bool) async throws -> [TeamHolder] {
switch fileProvider {
case .frenchFederation:
@ -186,9 +193,9 @@ class FileImportManager {
case .padelClub:
return await _getPadelClubTeams(from: fileContent, tournament: tournament)
case .custom:
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: false, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: false, tournament: tournament)
case .customAutoSearch:
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: true, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: true, tournament: tournament)
}
}
@ -278,9 +285,9 @@ class FileImportManager {
FederalTournamentAge.allCases.first(where: { $0.importingRawValue.canonicalVersion == ageCategory.canonicalVersion }) ?? .senior
}
let resultOne = Array(dataOne.dropFirst(3).dropLast())
let resultTwo = Array(dataTwo.dropFirst(3).dropLast())
let sexUnknown: Bool = (resultOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (resultTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
let resultOne = Array(dataOne.dropFirst(3).dropLast(3))
let resultTwo = Array(dataTwo.dropFirst(3).dropLast(3))
let sexUnknown: Bool = (dataOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (dataTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
var sexPlayerOne : Int {
switch tournamentCategory {
@ -302,12 +309,14 @@ class FileImportManager {
if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled {
let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -363,12 +372,14 @@ class FileImportManager {
let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -399,6 +410,7 @@ class FileImportManager {
let registeredPlayers = found?.map({ importedPlayer in
let player = PlayerRegistration(importedPlayer: importedPlayer)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
return player
})
if let registeredPlayers, registeredPlayers.isEmpty == false {
@ -416,7 +428,7 @@ class FileImportManager {
return nil
}
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
results.append(team)
}
}
@ -424,7 +436,7 @@ class FileImportManager {
return results
}
private func _getPadelBusinessLeagueTeams(from fileContent: String, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
private func _getPadelBusinessLeagueTeams(from fileContent: String, chunkByParameter: Bool, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
let lines = fileContent.replacingOccurrences(of: "\"", with: "").components(separatedBy: "\n")
guard let firstLine = lines.first else { return [] }
var separator = ","
@ -434,39 +446,84 @@ class FileImportManager {
let fetchRequest = ImportedPlayer.fetchRequest()
let federalContext = PersistenceController.shared.localContainer.viewContext
let results: [TeamHolder] = lines.chunked(into: 2).map { team in
var chunks: [[String]] = []
if chunkByParameter {
chunks = lines.chunked(byParameterAt: 1)
} else {
chunks = lines.chunked(into: 2)
}
let results = chunks.map { team in
var teamName: String? = nil
let players = team.map { player in
let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.trimmed ?? ""
let firstName : String = data[safe: 3]?.trimmed ?? ""
let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerSexType = data[safe: 0] == "f" ? PlayerSexType.female : PlayerSexType.male
if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed
}
let phoneNumber : String? = data[safe: 4]?.trimmed
let email : String? = data[safe: 5]?.trimmed
let phoneNumber : String? = data[safe: 4]?.trimmed.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).prefixTrimmed(50)
let email : String? = data[safe: 5]?.prefixTrimmed(50)
let rank : Int? = data[safe: 6]?.trimmed.toInt()
let licenceId : String? = data[safe: 7]?.trimmed
let club : String? = data[safe: 8]?.trimmed
let licenceId : String? = data[safe: 7]?.prefixTrimmed(50)
let club : String? = data[safe: 8]?.prefixTrimmed(200)
let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName)
fetchRequest.predicate = predicate
let found = try? federalContext.fetch(fetchRequest).first
if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player
} else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
if rank == nil, autoSearch {
player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
}
return player
}
}
return TeamHolder(players: players, tournamentCategory: .men, tournamentAgeCategory: .senior, previousTeam: nil, name: teamName, tournament: tournament)
return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: nil, name: teamName, tournament: tournament)
}
return results
}
}
extension Array where Element == String {
/// Groups the array of CSV lines based on the same value at the specified column index.
/// If no key is found, it defaults to chunking the array into groups of 2 lines.
/// - Parameter index: The index of the CSV column to group by.
/// - Returns: An array of arrays, where each inner array contains lines grouped by the CSV parameter or by default chunks of 2.
func chunked(byParameterAt index: Int) -> [[String]] {
var groups: [String: [String]] = [:]
for line in self {
let columns = line.split(separator: ";", omittingEmptySubsequences: false).map { String($0) }
if index < columns.count {
let key = columns[index]
if groups[key] == nil {
groups[key] = []
}
groups[key]?.append(line)
} else {
// Handle out-of-bounds by continuing
print("Warning: Index \(index) out of bounds for line: \(line)")
}
}
// If no valid groups found, chunk into groups of 2 lines
if groups.isEmpty {
return self.chunked(into: 2)
} else {
// Append groups by parameter value, converting groups.values into an array of arrays
return groups.map { $0.value }
}
}
}

@ -9,6 +9,7 @@ import Foundation
import UIKit
import WebKit
import PDFKit
import PadelClubData
class HtmlGenerator: ObservableObject {
@ -24,6 +25,10 @@ class HtmlGenerator: ObservableObject {
@Published var displayHeads: Bool = false
@Published var groupStageIsReady: Bool = false
@Published var displayRank: Bool = false
@Published var displayTeamIndex: Bool = false
@Published var displayScore: Bool = false
@Published var displayPlannedDate: Bool = true
private var pdfDocument: PDFDocument = PDFDocument()
private var rects: [CGRect] = []
private var completionHandler: ((Result<Bool, Error>) -> ())?
@ -58,6 +63,8 @@ class HtmlGenerator: ObservableObject {
func generateWebView(webView: WKWebView) {
self.webView = webView
#if targetEnvironment(simulator)
#else
self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
print("evaluateJavaScript", "readystage", complete, error)
if complete != nil {
@ -74,9 +81,12 @@ class HtmlGenerator: ObservableObject {
})
}
})
#endif
}
func generateGroupStage(webView: WKWebView) {
#if targetEnvironment(simulator)
#else
webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
if complete != nil {
webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
@ -111,7 +121,7 @@ class HtmlGenerator: ObservableObject {
})
}
})
#endif
}
func buildPDF() {
@ -145,6 +155,8 @@ class HtmlGenerator: ObservableObject {
}
func createPage() {
#if targetEnvironment(simulator)
#else
let config = WKPDFConfiguration()
config.rect = rects[pdfDocument.pageCount]
webView.createPDF(configuration: config){ result in
@ -163,16 +175,21 @@ class HtmlGenerator: ObservableObject {
self.completionHandler?(.failure(error))
}
}
#endif
}
func generateHtml() -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.template(tournament: tournament).html(headName: displayHeads, withRank: displayRank, withScore: false)
HtmlService.template(tournament: tournament).html(options: options)
}
func generateLoserBracketHtml(upperRound: Round) -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.loserBracket(upperRound: upperRound).html(headName: displayHeads, withRank: displayRank, withScore: false)
HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(options: options)
}
var options: HtmlOptions {
HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate, includeLoserBracket: includeLoserBracket)
}
var pdfURL: URL? {
@ -186,7 +203,7 @@ class HtmlGenerator: ObservableObject {
.day()
.dateSeparator(.dash))
let name = tournament.tournamentLevel.localizedLabel() + "-" + tournament.tournamentCategory.importingRawValue
let name = tournament.tournamentLevel.localizedLevelLabel() + "-" + tournament.tournamentCategory.importingRawValue
return pdfFolderURL.appendingPathComponent(stringDate + "-" + name + ".pdf")
}

@ -6,12 +6,39 @@
//
import Foundation
import PadelClubData
struct HtmlOptions {
let headName: Bool
let withRank: Bool
let withTeamIndex: Bool
let withScore: Bool
let withPlannedDate: Bool
let includeLoserBracket: Bool
// Default initializer with all options defaulting to true
init(
headName: Bool = true,
withRank: Bool = true,
withTeamIndex: Bool = true,
withScore: Bool = true,
withPlannedDate: Bool = true,
includeLoserBracket: Bool = false
) {
self.headName = headName
self.withRank = withRank
self.withTeamIndex = withTeamIndex
self.withScore = withScore
self.withPlannedDate = withPlannedDate
self.includeLoserBracket = includeLoserBracket
}
}
enum HtmlService {
case template(tournament: Tournament)
case bracket(round: Round)
case loserBracket(upperRound: Round)
case loserBracket(upperRound: Round, hideTitle: Bool)
case match(match: Match)
case player(entrant: TeamRegistration)
case hiddenPlayer
@ -50,7 +77,7 @@ enum HtmlService {
}
}
func html(headName: Bool, withRank: Bool, withScore: Bool) -> String {
func html(options: HtmlOptions = HtmlOptions()) -> String {
guard let file = Bundle.main.path(forResource: self.fileName, ofType: "html") else {
fatalError()
}
@ -69,12 +96,12 @@ enum HtmlService {
}
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: bracket.tournamentObject()!.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{bracketTitle}}", with: bracket.groupStageTitle())
template = template.replacingOccurrences(of: "{{formatLabel}}", with: bracket.matchFormat.formatTitle())
var col = ""
var row = ""
bracket.teams().forEach { entrant in
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(headName: headName, withRank: withRank, withScore: withScore))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(headName: headName, withRank: withRank, withScore: withScore))
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(options: options))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(options: options))
}
template = template.replacingOccurrences(of: "{{teamsCol}}", with: col)
template = template.replacingOccurrences(of: "{{teamsRow}}", with: row)
@ -82,9 +109,15 @@ enum HtmlService {
return template
case .groupstageEntrant(let entrant):
var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.seedIndex() ?? "")
}
if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if withRank {
if options.withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -96,7 +129,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if withRank {
if options.withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -108,7 +141,7 @@ enum HtmlService {
return template
case .groupstageRow(let entrant, let teamsPerBracket):
var template = html
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(options: options))
var scores = ""
(0..<teamsPerBracket).forEach { index in
@ -117,31 +150,38 @@ enum HtmlService {
if shouldHide == false {
match = entrant.groupStageObject()?.matchPlayed(by: entrant.groupStagePosition!, againstPosition: index)
}
scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(headName: headName, withRank: withRank, withScore: withScore))
scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(options: options))
}
template = template.replacingOccurrences(of: "{{scores}}", with: scores)
return template
case .groupstageColumn(let entrant, let position):
var template = html
template = template.replacingOccurrences(of: "{{tablePosition}}", with: position)
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(options: options))
return template
case .groupstageScore(let match, let shouldHide):
var template = html
if match == nil || withScore == false {
if match == nil || options.withScore == false {
template = template.replacingOccurrences(of: "{{winner}}", with: "")
template = template.replacingOccurrences(of: "{{score}}", with: "")
} else {
template = template.replacingOccurrences(of: "{{winner}}", with: match!.winner()!.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match!.scoreLabel())
} else if let match, let winner = match.winner() {
template = template.replacingOccurrences(of: "{{winner}}", with: winner.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match.scoreLabel())
}
template = template.replacingOccurrences(of: "{{hide}}", with: shouldHide ? "hide" : "")
return template
case .player(let entrant):
var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.formattedSeed())
}
if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if withRank {
if options.withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -153,7 +193,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if withRank {
if options.withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -164,18 +204,33 @@ enum HtmlService {
}
return template
case .hiddenPlayer:
return html + html
var template = html + html
if options.withTeamIndex {
template += html
}
return template
case .match(let match):
var template = html
if options.withPlannedDate, let plannedStartDate = match.plannedStartDate {
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: plannedStartDate.localizedDate())
} else {
}
if let entrantOne = match.team(.one) {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(options: options))
if options.withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: [top.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
} else {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(options: options))
}
if let entrantTwo = match.team(.two) {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(options: options))
if options.withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: [bottom.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
} else {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(options: options))
}
if match.disabled {
template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden")
@ -188,27 +243,45 @@ enum HtmlService {
} else if match.teamWon(atPosition: .two) == true {
template = template.replacingOccurrences(of: "{{entrantTwoWon}}", with: "winner")
}
template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n"))
// template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{matchDescription}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: "")
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: "")
return template
case .bracket(let round):
var template = ""
var bracket = ""
for (_, match) in round._matches().enumerated() {
template = template.appending(HtmlService.match(match: match).html(headName: headName, withRank: withRank, withScore: withScore))
template = template.appending(HtmlService.match(match: match).html(options: options))
}
bracket = html.replacingOccurrences(of: "{{match-template}}", with: template)
bracket = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle())
bracket = bracket.replacingOccurrences(of: "{{formatLabel}}", with: round.matchFormat.formatTitle())
return bracket
case .loserBracket(let upperRound):
case .loserBracket(let upperRound, let hideTitle):
var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: upperRound.correspondingLoserRoundTitle())
if let tournamentStartDate = upperRound.initialStartDate()?.localizedDate() {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournamentStartDate)
} else {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: "")
}
template = template.replacingOccurrences(of: "{{titleHidden}}", with: hideTitle ? "hidden" : "")
var brackets = ""
for round in upperRound.loserRounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
var winnerName = ""
}
let winnerName = ""
let winner = """
<ul class="round" scope="last">
<li class="spacer">&nbsp;</li>
@ -221,18 +294,35 @@ enum HtmlService {
brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
for round in upperRound.loserRounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
return template
case .template(let tournament):
var template = html
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.title))
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournament.formattedDate())
var brackets = ""
for round in tournament.rounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if options.includeLoserBracket {
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
}
var winnerName = ""
if let tournamentWinner = tournament.tournamentWinner() {
winnerName = HtmlService.player(entrant: tournamentWinner).html(headName: headName, withRank: withRank, withScore: withScore)
winnerName = HtmlService.player(entrant: tournamentWinner).html(options: options)
}
let winner = """
<ul class="round" scope="last">
@ -246,6 +336,16 @@ enum HtmlService {
brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
if options.includeLoserBracket {
for round in tournament.rounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
}
return template
}
}

@ -16,7 +16,18 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published var postalCode: String?
@Published var requestStarted: Bool = false
@Published var userReadableCityOrZipcode: String = ""
@Published var lastError: Error? = nil
@Published var lastError: LocalizedError? = nil
enum LocationError: LocalizedError {
case unknownError(error: Error)
var errorDescription: String? {
switch self {
case .unknownError(let error):
return "Padel Club n'a pas réussi à vous localiser."
}
}
}
override init() {
super.init()
@ -26,6 +37,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func requestLocation() {
lastError = nil
manager.requestLocation()
city = nil
location = nil
requestStarted = true
}
@ -49,7 +62,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("locationManager didFailWithError", error)
requestStarted = false
self.lastError = error
self.lastError = LocationError.unknownError(error: error)
}
func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) {

@ -0,0 +1,90 @@
//
// ConfigurationService.swift
// PadelClub
//
// Created by razmig on 14/04/2025.
//
import Foundation
import LeStorage
class ConfigurationService {
static func fetchTournamentConfig() async throws -> TimeToConfirmConfig {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "config/tournament/", method: .get, requiresToken: true)
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try JSONDecoder().decode(TimeToConfirmConfig.self, from: data)
}
static func fetchPaymentConfig() async throws -> PaymentConfig {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "config/payment/", method: .get, requiresToken: true)
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try JSONDecoder().decode(PaymentConfig.self, from: data)
}
}
struct TimeToConfirmConfig: Codable {
let timeProximityRules: [String: Int]
let waitingListRules: [String: Int]
let businessRules: BusinessRules
let minimumResponseTime: Int
private enum CodingKeys: String, CodingKey {
case timeProximityRules = "time_proximity_rules"
case waitingListRules = "waiting_list_rules"
case businessRules = "business_rules"
case minimumResponseTime = "minimum_response_time"
}
// Default configuration
static let defaultConfig = TimeToConfirmConfig(
timeProximityRules: [
"24": 30, // within 24h 30 min
"48": 60, // within 48h 60 min
"72": 120, // within 72h 120 min
"default": 240
],
waitingListRules: [
"30": 30, // 30+ teams 30 min
"20": 60, // 20+ teams 60 min
"10": 120, // 10+ teams 120 min
"default": 240
],
businessRules: BusinessRules(
hours: Hours(
start: 8,
end: 21
)
),
minimumResponseTime: 30
)
}
struct BusinessRules: Codable {
let hours: Hours
}
struct Hours: Codable {
let start: Int
let end: Int
private enum CodingKeys: String, CodingKey {
case start
case end
}
}
struct PaymentConfig: Codable {
let stripeFee: Double
// Default configuration
static let defaultConfig = PaymentConfig(stripeFee: 0.0075)
private enum CodingKeys: String, CodingKey {
case stripeFee = "stripe_fee"
}
}

@ -0,0 +1,302 @@
//
// FederalDataService.swift
// PadelClub
//
// Created by Razmig Sarkissian on 09/07/2025.
//
import Foundation
import CoreLocation
import LeStorage
import PadelClubData
struct UmpireContactInfo: Codable {
let name: String?
let email: String?
let phone: String?
}
/// Response model for the batch umpire data endpoint
struct UmpireDataResponse: Codable {
let results: [String: UmpireContactInfo]
}
// New struct for the response from get_fft_club_tournaments and get_fft_all_tournaments
struct TournamentsAPIResponse: Codable {
let success: Bool
let tournaments: [FederalTournament]
let totalResults: Int
let currentCount: Int
let pagesScraped: Int? // Optional, as it might not always be present or relevant
let page: Int? // Optional, as it might not always be present or relevant
let umpireDataIncluded: Bool? // Only for get_fft_club_tournaments_with_umpire_data
let message: String
private enum CodingKeys: String, CodingKey {
case success
case tournaments
case totalResults = "total_results"
case currentCount = "current_count"
case pagesScraped = "pages_scraped"
case page
case umpireDataIncluded = "umpire_data_included"
case message
}
}
// MARK: - FederalDataService
/// `FederalDataService` handles all API calls related to federal data (clubs, tournaments, umpire info).
/// All direct interactions with `tenup.fft.fr` are now assumed to be handled by your backend.
class FederalDataService {
static let shared: FederalDataService = FederalDataService()
// The 'formId', 'tenupJsonDecoder', 'runTenupTask', and 'getNewBuildForm'
// from the legacy NetworkFederalService are removed as their logic is now
// handled server-side.
/// Fetches federal clubs based on geographic criteria.
/// - Parameters:
/// - country: The country code (e.g., "fr").
/// - city: The city name or address for search.
/// - radius: The search radius in kilometers.
/// - location: Optional `CLLocation` for user's precise position to calculate distance.
/// - Returns: A `FederalClubResponse` object containing a list of clubs and total count.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "country", value: country),
URLQueryItem(name: "city", value: city),
URLQueryItem(name: "radius", value: String(Int(radius)))
]
if let location = location {
queryItems.append(URLQueryItem(name: "lat", value: location.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us")))))
queryItems.append(URLQueryItem(name: "lng", value: location.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us")))))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for federal clubs: 'fft/federal-clubs/'
let urlRequest = try service._baseRequest(servicePath: "fft/federal-clubs?\(queryString)", method: .get, requiresToken: false)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse) // Keep URLError for generic network issues
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
return try JSONDecoder().decode(FederalClubResponse.self, from: data)
} catch {
print("Decoding error for FederalClubResponse: \(error)")
// Map decoding error to a generic API error
throw NetworkManagerError.apiError("Failed to decode FederalClubResponse: \(error.localizedDescription)")
}
}
/// Fetches federal tournaments for a specific club.
/// This function now calls your backend, which in turn handles the `form_build_id` and pagination.
/// The `tournaments` parameter is maintained for signature compatibility but is not used for server-side fetching.
/// Client-side accumulation of results from multiple pages should be handled by the caller.
/// - Parameters:
/// - page: The current page number for pagination.
/// - tournaments: An array of already gathered tournaments (for signature compatibility; not used internally for fetching).
/// - club: The name of the club.
/// - codeClub: The unique code of the club.
/// - startDate: Optional start date for filtering tournaments.
/// - endDate: Optional end date for filtering tournaments.
/// - Returns: An array of `FederalTournament` objects for the requested page.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> TournamentsAPIResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "club_code", value: codeClub),
URLQueryItem(name: "club_name", value: club),
URLQueryItem(name: "page", value: String(page))
]
if let startDate = startDate {
queryItems.append(URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted))
}
if let endDate = endDate {
queryItems.append(URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for club tournaments: 'fft/club-tournaments/'
let urlRequest = try service._baseRequest(servicePath: "fft/club-tournaments?\(queryString)", method: .get, requiresToken: false)
print(urlRequest.url?.absoluteString)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
// Your backend should return a direct array of FederalTournament for the requested page
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data)
return federalTournaments
} catch {
print("Decoding error for FederalTournament array: \(error)")
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)")
}
}
/// Fetches all federal tournaments based on various filtering options.
/// This function now calls your backend, which handles the complex filtering and data retrieval.
/// The return type `[HttpCommand]` is maintained for signature compatibility,
/// wrapping the actual `[FederalTournament]` data within an `HttpCommand` structure.
/// - Parameters:
/// - sortingOption: How to sort the results (e.g., "dateDebut asc").
/// - page: The current page number for pagination.
/// - startDate: The start date for the tournament search.
/// - endDate: The end date for the tournament search.
/// - city: The city to search within.
/// - distance: The search distance from the city.
/// - categories: An array of `TournamentCategory` to filter by.
/// - levels: An array of `TournamentLevel` to filter by.
/// - lat: Optional latitude for precise location search.
/// - lng: Optional longitude for precise location search.
/// - ages: An array of `FederalTournamentAge` to filter by.
/// - types: An array of `FederalTournamentType` to filter by.
/// - nationalCup: A boolean indicating if national cup tournaments should be included.
/// - Returns: An array of `HttpCommand` objects, containing the `FederalTournament` data.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getAllFederalTournaments(
sortingOption: String,
page: Int,
startDate: Date,
endDate: Date,
city: String,
distance: Double,
categories: [TournamentCategory],
levels: [TournamentLevel],
lat: String?,
lng: String?,
ages: [FederalTournamentAge],
types: [FederalTournamentType],
nationalCup: Bool
) async throws -> TournamentsAPIResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "sort", value: sortingOption),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted),
URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted),
URLQueryItem(name: "city", value: city),
URLQueryItem(name: "distance", value: String(Int(distance))),
URLQueryItem(name: "national_cup", value: nationalCup ? "true" : "false")
]
if let lat = lat, !lat.isEmpty {
queryItems.append(URLQueryItem(name: "lat", value: lat))
}
if let lng = lng, !lng.isEmpty {
queryItems.append(URLQueryItem(name: "lng", value: lng))
}
// Add array parameters (assuming your backend can handle comma-separated or multiple query params)
if !categories.isEmpty {
queryItems.append(URLQueryItem(name: "categories", value: categories.map { String($0.rawValue) }.joined(separator: ",")))
}
if !levels.isEmpty {
queryItems.append(URLQueryItem(name: "levels", value: levels.map { String($0.rawValue) }.joined(separator: ",")))
}
if !ages.isEmpty {
queryItems.append(URLQueryItem(name: "ages", value: ages.map { String($0.rawValue) }.joined(separator: ",")))
}
if !types.isEmpty {
queryItems.append(URLQueryItem(name: "types", value: types.map { $0.rawValue }.joined(separator: ",")))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for all tournaments: 'fft/all-tournaments/'
var urlRequest = try service._baseRequest(servicePath: "fft/all-tournaments?\(queryString)", method: .get, requiresToken: true)
urlRequest.timeoutInterval = 180
let (data, response) = try await URLSession.shared.data(for: urlRequest)
print(urlRequest.url?.absoluteString ?? "No URL")
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
// Your backend should return a direct array of FederalTournament
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data)
return federalTournaments
} catch {
print("Decoding error for FederalTournament array in getAllFederalTournaments: \(error)")
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)")
}
}
/// Fetches umpire contact data for a given tournament ID.
/// This function now calls your backend, which performs the HTML scraping.
/// The return type is maintained for signature compatibility, mapping `UmpireContactInfo` to a tuple.
/// - Parameter idTournament: The ID of the tournament.
/// - Returns: A tuple `(name: String?, email: String?, phone: String?)` containing the umpire's contact info.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) {
let service = try StoreCenter.main.service()
// The servicePath now points to your backend's endpoint for umpire data: 'fft/umpire/{tournament_id}/'
let servicePath = "fft/umpire/\(idTournament)/"
var urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false)
urlRequest.timeoutInterval = 120.0
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
let umpireInfo = try JSONDecoder().decode(UmpireContactInfo.self, from: data)
// Map the decoded struct to the tuple required by the legacy signature
print(umpireInfo)
return (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone)
} catch {
print("Decoding error for UmpireContactInfo: \(error)")
throw NetworkManagerError.apiError("Failed to decode UmpireContactInfo: \(error.localizedDescription)")
}
}
}

@ -7,6 +7,7 @@
import Foundation
import CoreLocation
import PadelClubData
class NetworkFederalService {
struct HttpCommand: Decodable {
@ -34,18 +35,39 @@ class NetworkFederalService {
}()
func runTenupTask<T: Decodable>(request: URLRequest) async throws -> T {
let task = try await URLSession.shared.data(for: request)
if request.httpMethod == "PUT" {
print("tried PUT: \(request.url!)")
if let urlResponse = task.1 as? HTTPURLResponse {
print(urlResponse.statusCode)
let (data, response) = try await URLSession.shared.data(for: request)
// Print request info
print("Request: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
// Print response status
if let httpResponse = response as? HTTPURLResponse {
print("Status code: \(httpResponse.statusCode)")
}
// Print JSON data before decoding
if let jsonObject = try? JSONSerialization.jsonObject(with: data) {
//print("Response JSON: \(jsonObject)")
} else {
print("Response is not a valid JSON")
// Try to print as string if not JSON
if let stringResponse = String(data: data, encoding: .utf8) {
print("Response as string: \(stringResponse)")
}
}
// Now try to decode
do {
return try tenupJsonDecoder.decode(T.self, from: data)
} catch {
print("Decoding error: \(error)")
throw error
}
return try tenupJsonDecoder.decode(T.self, from: task.0)
}
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse {
return try await FederalDataService.shared.federalClubs(country: country, city: city, radius: radius, location: location)
/*
{
"geocoding[country]": "fr",
@ -71,7 +93,7 @@ class NetworkFederalService {
//"geocoding%5Bcountry%5D=fr&geocoding%5Bville%5D=13%20Avenue%20Emile%20Bodin%2013260%20Cassis&geocoding%5Brayon%5D=15&geocoding%5BuserPosition%5D%5Blat%5D=43.22278594081477&geocoding%5BuserPosition%5D%5Blng%5D=5.556953900769194&geocoding%5BuserPosition%5D%5BshowDistance%5D=true&nombreResultat=0&diplomeEtatOption=false&galaxieOption=false&fauteuilOption=false&tennisSanteOption=false"
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!,timeoutInterval: Double.infinity)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!)
request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
@ -93,105 +115,11 @@ class NetworkFederalService {
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> [FederalTournament] {
if formId.isEmpty {
do {
try await getNewBuildForm()
} catch {
print("getClubFederalTournaments", error)
}
}
var dateComponent = ""
if let startDate, let endDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(endDate.endOfMonth.twoDigitsYearFormatted)"
} else if let startDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(Calendar.current.date(byAdding: .month, value: 3, to: startDate)!.endOfMonth.twoDigitsYearFormatted)"
}
let parameters = """
recherche_type=club&club[autocomplete][value_container][value_field]=\(codeClub.replaceCharactersFromSet(characterSet: .whitespaces))&club[autocomplete][value_container][label_field]=\(club.replaceCharactersFromSet(characterSet: .whitespaces, replacementString: "+"))&pratique=PADEL\(dateComponent)&page=\(page)&sort=dateDebut+asc&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page
"""
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.httpMethod = "POST"
request.httpBody = postData
let commands : [HttpCommand] = try await runTenupTask(request: request)
if commands.anySatisfy({ $0.command == "alert" }) {
throw NetworkManagerError.maintenance
}
let resultCommand = commands.first(where: { $0.results != nil })
if let gatheredTournaments = resultCommand?.results?.items {
var finalTournaments = tournaments + gatheredTournaments
if let count = resultCommand?.results?.nb_results {
if finalTournaments.count < count {
let newTournaments = try await getClubFederalTournaments(page: page+1, tournaments: finalTournaments, club: club, codeClub: codeClub)
finalTournaments = finalTournaments + newTournaments
}
}
return finalTournaments
return try await FederalDataService.shared.getClubFederalTournaments(page: page, tournaments: tournaments, club: club, codeClub: codeClub, startDate: startDate, endDate: endDate).tournaments
}
// do {
// } catch {
// print("getClubFederalTournaments", error)
// }
//
return []
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) {
return try await FederalDataService.shared.getUmpireData(idTournament: idTournament)
}
func getNewBuildForm() async throws {
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/tournois")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.addValue("trailers", forHTTPHeaderField: "TE")
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
if let stringData = String(data: task.0, encoding: .utf8) {
let stringDataFolded = stringData.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines)
let prefix = "form_build_id\"value=\"form-"
var finalData = ""
if let lab = stringDataFolded.matches(of: try! Regex("\(prefix)")).last {
finalData = String(stringDataFolded[lab.range.upperBound...])
}
let suffix = "\"/><inputtype=\"hidden\"name=\"form_id\"value=\"recherche_tournois_form"
if let suff = finalData.firstMatch(of: try! Regex("\(suffix)")) {
finalData = String(finalData[..<suff.range.lowerBound])
}
print(finalData)
formId = "form-\(finalData)"
} else {
print("no data found in html")
}
}
}

@ -6,6 +6,7 @@
//
import Foundation
import PadelClubData
class NetworkManager {
static let shared: NetworkManager = NetworkManager()
@ -51,9 +52,9 @@ class NetworkManager {
let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
let fileURL = URL(string: "https://xlr.alwaysdata.net/static/rankings/\(dateString)")
let fileURL = URLs.main.extend(path: "static/rankings/\(dateString)")
var request = URLRequest(url:fileURL!)
var request = URLRequest(url:fileURL)
request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition")
if FileManager.default.fileExists(atPath: destinationFileUrl.path()), let modificationDate = destinationFileUrl.creationDate() {
request.addValue(formatDateForHTTPHeader(modificationDate), forHTTPHeaderField: "If-Modified-Since")

@ -1,28 +0,0 @@
//
// NetworkManagerError.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
enum NetworkManagerError: LocalizedError {
case maintenance
case fileNotYetAvailable
case mailFailed
case mailNotSent //no network no error
case messageFailed
case messageNotSent //no network no error
case fileNotModified
case fileNotDownloaded(Int)
var errorDescription: String? {
switch self {
case .maintenance:
return "Le site de la FFT est en maintenance"
default:
return String(describing: self)
}
}
}

@ -0,0 +1,77 @@
//
// PaymentService.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/10/2025.
//
import Foundation
import LeStorage
import PadelClubData
class PaymentService {
static func resendPaymentEmail(teamRegistrationId: String) async throws -> SimpleResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(
servicePath: "resend-payment-email/\(teamRegistrationId)/",
method: .post,
requiresToken: true
)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PaymentError.requestFailed
}
return try JSON.decoder.decode(SimpleResponse.self, from: data)
}
static func getPaymentLink(teamRegistrationId: String) async throws -> PaymentLinkResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(
servicePath: "payment-link/\(teamRegistrationId)/",
method: .get,
requiresToken: true
)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PaymentError.requestFailed
}
// // Debug: Print the raw JSON response
// if let jsonString = String(data: data, encoding: .utf8) {
// print("Raw JSON Response: \(jsonString)")
// }
return try JSON.decoder.decode(PaymentLinkResponse.self, from: data)
}
}
struct PaymentLinkResponse: Codable {
let success: Bool
let paymentLink: String?
let message: String?
enum CodingKeys: String, CodingKey {
case success
case paymentLink
case message
}
}
enum PaymentError: Error {
case requestFailed
case unauthorized
case unknown
}
struct SimpleResponse: Codable {
let success: Bool
let message: String
}

@ -0,0 +1,50 @@
//
// RefundService.swift
// PadelClub
//
// Created by razmig on 11/04/2025.
//
import Foundation
import LeStorage
import PadelClubData
class RefundService {
static func processRefund(teamRegistrationId: String) async throws -> RefundResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "refund-tournament/\(teamRegistrationId)/", method: .post, requiresToken: true)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw RefundError.requestFailed
}
let refundResponse = try JSON.decoder.decode(RefundResponse.self, from: data)
return refundResponse
}
}
struct RefundResponse: Codable {
let success: Bool
let message: String
let players: [PlayerRegistration]?
enum CodingKeys: String, CodingKey {
case success
case message
case players
}
}
enum RefundError: Error {
case requestFailed
case unauthorized
case unknown
}
struct RefundResult {
let team: TeamRegistration
let response: Result<RefundResponse, Error>
}

@ -0,0 +1,207 @@
//
// StripeValidationService.swift
// PadelClub
//
// Created by razmig on 12/04/2025.
//
import Foundation
import LeStorage
class StripeValidationService {
// MARK: - Validate Stripe Account
static func validateStripeAccount(accountId: String) async throws -> ValidationResponse {
let service = try StoreCenter.main.service()
var urlRequest = try service._baseRequest(servicePath: "validate-stripe-account/", method: .post, requiresToken: true)
var body: [String: Any] = [:]
body["account_id"] = accountId
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(ValidationResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
// Handle client errors - still decode as ValidationResponse
let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
// MARK: - Create Stripe Connect Account
static func createStripeConnectAccount() async throws -> CreateAccountResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "stripe/create-account/", method: .post, requiresToken: true)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
let errorResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
// MARK: - Create Stripe Account Link
static func createStripeAccountLink(_ accountId: String? = nil) async throws -> CreateLinkResponse {
let service = try StoreCenter.main.service()
var urlRequest = try service._baseRequest(servicePath: "stripe/create-account-link/", method: .post, requiresToken: true)
var body: [String: Any] = [:]
if let accountId = accountId {
body["account_id"] = accountId
}
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
let errorResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
}
// MARK: - Response Models
struct ValidationResponse: Codable {
let valid: Bool
let canProcessPayments: Bool?
let onboardingComplete: Bool?
let needsOnboarding: Bool?
let account: AccountDetails?
let error: String?
enum CodingKeys: String, CodingKey {
case valid
case canProcessPayments = "can_process_payments"
case onboardingComplete = "onboarding_complete"
case needsOnboarding = "needs_onboarding"
case account
case error
}
}
struct AccountDetails: Codable {
let id: String
let chargesEnabled: Bool?
let payoutsEnabled: Bool?
let detailsSubmitted: Bool?
enum CodingKeys: String, CodingKey {
case id
case chargesEnabled = "charges_enabled"
case payoutsEnabled = "payouts_enabled"
case detailsSubmitted = "details_submitted"
}
}
struct CreateAccountResponse: Codable {
let success: Bool
let accountId: String?
let message: String?
let existing: Bool?
let error: String?
enum CodingKeys: String, CodingKey {
case success
case accountId = "account_id"
case message
case existing
case error
}
}
struct CreateLinkResponse: Codable {
let success: Bool
let url: URL?
let accountId: String?
let error: String?
enum CodingKeys: String, CodingKey {
case success
case url
case accountId = "account_id"
case error
}
}
enum ValidationError: Error {
case invalidResponse
case networkError(Error)
case invalidData
case encodingError
case urlNotFound
case accountNotFound
case onlinePaymentNotEnabled
var localizedDescription: String {
switch self {
case .invalidResponse:
return "Réponse du serveur invalide"
case .networkError(let error):
return "Erreur réseau : \(error.localizedDescription)"
case .invalidData:
return "Données reçues invalides"
case .encodingError:
return "Échec de l'encodage des données de la requête"
case .accountNotFound:
return "Le compte n'a pas pu être généré"
case .urlNotFound:
return "Le lien pour utiliser un compte stripe n'a pas pu être généré"
case .onlinePaymentNotEnabled:
return "Le paiement en ligne n'a pas pu être activé pour ce tournoi"
}
}
}

@ -0,0 +1,83 @@
//
// XlsToCsvService.swift
// PadelClub
//
// Created by razmig on 12/04/2025.
//
import Foundation
import LeStorage
class XlsToCsvService {
static func exportToCsv(url: URL) async throws -> String {
let service = try StoreCenter.main.service()
var request = try service._baseRequest(servicePath: "xls-to-csv/", method: .post, requiresToken: true)
// Create the boundary string for multipart/form-data
let boundary = UUID().uuidString
// Set the content type to multipart/form-data with the boundary
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
// The file to upload
let fileName = url.lastPathComponent
let fileURL = url
// Construct the body of the request
var body = Data()
// Start the body with the boundary and content-disposition for the file
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/vnd.ms-excel\r\n\r\n".data(using: .utf8)!)
// Append the file data
if let fileData = try? Data(contentsOf: fileURL) {
body.append(fileData)
}
// End the body with the boundary
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
// Set the body of the request
request.httpBody = body
let (data, response) = try await URLSession.shared.data(for: request)
// Check the response status code
if let httpResponse = response as? HTTPURLResponse {
print("Status code: \(httpResponse.statusCode)")
}
// Convert the response data to a String
if let responseString = String(data: data, encoding: .utf8) {
return responseString
} else {
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide")
throw ConvertionError.serviceError(error)
}
}
}
struct ErrorResponse: Decodable {
let code: Int
let status, error: String
}
enum ConvertionError: LocalizedError {
case unknownError
case serviceError(ErrorResponse)
case urlNotFound(String)
var errorDescription: String? {
switch self {
case .unknownError:
return "Erreur"
case .serviceError(let errorResponse):
return errorResponse.error
case .urlNotFound(let url):
return "L'URL [\(url)] n'est pas valide"
}
}
}

@ -0,0 +1,59 @@
import Foundation
func areFrenchPhoneNumbersSimilar(_ phoneNumber1: String?, _ phoneNumber2: String?) -> Bool {
if phoneNumber1?.canonicalVersion == phoneNumber2?.canonicalVersion {
return true
}
// Helper function to normalize a phone number, now returning an optional String
func normalizePhoneNumber(_ numberString: String?) -> String? {
// 1. Safely unwrap the input string. If it's nil or empty, return nil immediately.
guard let numberString = numberString, !numberString.isEmpty else {
return nil
}
// 2. Remove all non-digit characters
let digitsOnly = numberString.filter(\.isNumber)
// If after filtering, there are no digits, return nil.
guard !digitsOnly.isEmpty else {
return nil
}
// 3. Handle French specific prefixes and extract the relevant part
// We need at least 9 digits to get a meaningful 8-digit comparison from the end
if digitsOnly.count >= 9 {
if digitsOnly.hasPrefix("0") {
return String(digitsOnly.suffix(9))
} else if digitsOnly.hasPrefix("33") {
// Ensure there are enough digits after dropping "33"
if digitsOnly.count >= 11 { // "33" + 9 digits = 11
return String(digitsOnly.dropFirst(2).suffix(9))
} else {
return nil // Not enough digits after dropping "33"
}
} else if digitsOnly.count == 9 { // Case like 612341234
return digitsOnly
} else { // More digits but no 0 or 33 prefix, take the last 9
return String(digitsOnly.suffix(9))
}
}
return nil // If it doesn't fit the expected patterns or is too short
}
// Normalize both phone numbers. If either results in nil, we can't compare.
guard let normalizedNumber1 = normalizePhoneNumber(phoneNumber1),
let normalizedNumber2 = normalizePhoneNumber(phoneNumber2) else {
return false
}
// Ensure both normalized numbers have at least 8 digits before comparing suffixes
guard normalizedNumber1.count >= 8 && normalizedNumber2.count >= 8 else {
return false // One or both numbers are too short to have 8 comparable digits
}
// Compare the last 8 digits
return normalizedNumber1.suffix(8) == normalizedNumber2.suffix(8)
}

@ -1,257 +0,0 @@
//
// SourceFileManager.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
import LeStorage
class SourceFileManager {
static let shared = SourceFileManager()
init() {
createDirectoryIfNeeded()
}
let rankingSourceDirectory : URL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "rankings")
func createDirectoryIfNeeded() {
let fileManager = FileManager.default
do {
let directoryURL = rankingSourceDirectory
// Check if the directory exists
if !fileManager.fileExists(atPath: directoryURL.path) {
// Directory does not exist, create it
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
print("Directory created at: \(directoryURL)")
} else {
print("Directory already exists at: \(directoryURL)")
}
} catch {
print("Error: \(error)")
}
}
var lastDataSource: String? {
DataStore.shared.appSettings.lastDataSource
}
func lastDataSourceDate() -> Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
func fetchData() async {
await fetchData(fromDate: Date())
// if let mostRecent = mostRecentDateAvailable, let current = Calendar.current.date(byAdding: .month, value: 1, to: mostRecent), current > mostRecent {
// await fetchData(fromDate: current)
// } else {
// }
}
func _removeAllData(fromDate current: Date) {
let lastStringDate = URL.importDateFormatter.string(from: current)
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"]
files.forEach { fileName in
NetworkManager.shared.removeRankingData(lastDateString: lastStringDate, fileName: fileName)
}
}
func exportToCSV(players: [FederalPlayer], sourceFileType: SourceFile, date: Date) {
let lastDateString = URL.importDateFormatter.string(from: date)
let dateString = ["CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].joined(separator: "-") + "." + "csv"
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)!
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
var csvText : String = ""
for player in players {
csvText.append(player.exportToCSV() + "\n")
}
do {
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8)
print("CSV file exported successfully.")
} catch {
print("Error writing CSV file:", error)
Logger.error(error)
}
}
actor SourceFileDownloadTracker {
var _downloadedFileStatus : Int? = nil
func updateIfNecessary(with successState: Int?) {
if successState != nil && (_downloadedFileStatus == nil || _downloadedFileStatus == 0) {
_downloadedFileStatus = successState
}
}
func getDownloadedFileStatus() -> Int? {
return _downloadedFileStatus
}
}
//return nil if no new files
//return 1 if new file to import
//return 0 if new file just to re-calc static data, no need to re-import
@discardableResult
func fetchData(fromDate current: Date) async -> Int? {
let lastStringDate = URL.importDateFormatter.string(from: current)
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"]
let sourceFileDownloadTracker = SourceFileDownloadTracker()
do {
try await withThrowingTaskGroup(of: Void.self) { group in // Mark 1
for file in files {
group.addTask { [sourceFileDownloadTracker] in
let success = try await NetworkManager.shared.downloadRankingData(lastDateString: lastStringDate, fileName: file)
await sourceFileDownloadTracker.updateIfNecessary(with: success)
}
}
try await group.waitForAll()
}
// if current < Date() {
// if let nextCurrent = Calendar.current.date(byAdding: .month, value: 1, to: current) {
// await fetchData(fromDate: nextCurrent)
// }
// }
} catch {
print("downloadRankingData", error)
if mostRecentDateAvailable == nil {
if let previousDate = Calendar.current.date(byAdding: .month, value: -1, to: current) {
await fetchData(fromDate: previousDate)
}
}
}
let downloadedFileStatus = await sourceFileDownloadTracker.getDownloadedFileStatus()
return downloadedFileStatus
}
func getAllFiles(initialDate: String = "08-2022") async {
let dates = monthsBetweenDates(startDateString: initialDate, endDateString: Date().monthYearFormatted)
.compactMap {
URL.importDateFormatter.date(from: $0)
}
.filter { date in
allFiles.contains(where: { $0.dateFromPath == date }) == false
}
try? await dates.concurrentForEach { date in
await self.fetchData(fromDate: date)
}
}
func monthsBetweenDates(startDateString: String, endDateString: String) -> [String] {
let dateFormatter = URL.importDateFormatter
guard let startDate = dateFormatter.date(from: startDateString),
let endDate = dateFormatter.date(from: endDateString) else {
return []
}
var months: [String] = []
var currentDate = startDate
let calendar = Calendar.current
while currentDate <= endDate {
let monthString = dateFormatter.string(from: currentDate)
months.append(monthString)
guard let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: currentDate) else {
break
}
currentDate = nextMonthDate
}
return months
}
func getUnrankValue(forMale: Bool, rankSourceDate: Date?) -> Int? {
let _rankSourceDate = rankSourceDate ?? mostRecentDateAvailable
let urls = allFiles(forMale).filter { $0.dateFromPath == _rankSourceDate }
return urls.compactMap { $0.getUnrankedValue() }.sorted().last
}
var mostRecentDateAvailable: Date? {
allFiles(false).first?.dateFromPath
}
func removeAllFilesFromServer() {
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil)
allFiles.filter { $0.pathExtension == "csv" }.forEach { url in
try? FileManager.default.removeItem(at: url)
}
}
func jsonFiles() -> [URL] {
let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in
url.pathExtension == "json"
})
return allJSONFiles
}
var allFiles: [URL] {
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in
url.pathExtension == "csv"
})
return (allFiles + (Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil) ?? [])).sorted { $0.dateFromPath == $1.dateFromPath ? $0.index < $1.index : $0.dateFromPath > $1.dateFromPath }
}
func allFiles(_ isManPlayer: Bool) -> [URL] {
allFiles.filter({ url in
url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue)
})
}
func allFilesSortedByDate(_ isManPlayer: Bool) -> [URL] {
return allFiles(isManPlayer)
}
static func isDateAfterUrlImportDate(date: Date, dateString: String) -> Bool {
guard let importDate = URL.importDateFormatter.date(from: dateString) else {
return false
}
return importDate.isEarlierThan(date)
}
}
enum SourceFile: String, CaseIterable {
case dames = "DAMES"
case messieurs = "MESSIEURS"
var filesFromServer: [URL] {
let rankingSourceDirectory = SourceFileManager.shared.rankingSourceDirectory
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil)
return allFiles.filter{$0.pathExtension == "csv" && $0.path().contains(rawValue)}
}
func currentURLs(importingDate: Date) -> [URL] {
var files = Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil)?.filter({ url in
url.path().contains(rawValue)
}) ?? []
files.append(contentsOf: filesFromServer)
return files.filter({ $0.dateFromPath == importingDate })
}
var isMan: Bool {
switch self {
case .dames:
return false
default:
return true
}
}
}

@ -6,6 +6,7 @@
//
import Foundation
import PadelClubData
typealias LineIterator = AsyncLineSequence<URL.AsyncBytes>.AsyncIterator
struct Line: Identifiable {
@ -70,18 +71,18 @@ struct Line: Identifiable {
struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Line
private let url: URL
let url: URL
private var lineIterator: LineIterator
private let seperator: Character
private let separator: Character
private let quoteCharacter: Character = "\""
private var lineNumber = 0
private let date: Date
let maleData: Bool
init(url: URL, seperator: Character = ";") {
init(url: URL, separator: Character = ";") {
self.date = url.dateFromPath
self.url = url
self.seperator = seperator
self.separator = separator
self.lineIterator = url.lines.makeAsyncIterator()
self.maleData = url.path().contains(SourceFile.messieurs.rawValue)
}
@ -139,7 +140,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
inQuote = !inQuote
continue
case seperator:
case separator:
if !inQuote {
data.append(currentString.isEmpty ? nil : currentString)
currentString = ""
@ -157,4 +158,63 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
return data
}
/// Splits the CSV file into multiple temporary CSV files, each containing `size` lines.
/// Returns an array of new `CSVParser` instances pointing to these chunked files.
func getChunkedParser(size: Int) async throws -> [CSVParser] {
var chunkedParsers: [CSVParser] = []
var currentChunk: [String] = []
var iterator = self.makeAsyncIterator()
var chunkIndex = 0
while let line = try await iterator.next()?.rawValue {
currentChunk.append(line)
// When the chunk reaches the desired size, write it to a file
if currentChunk.count == size {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
chunkIndex += 1
currentChunk.removeAll()
}
}
// Handle remaining lines (if any)
if !currentChunk.isEmpty {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
}
return chunkedParsers
}
/// Writes a chunk of CSV lines to a temporary file and returns its URL.
private func writeChunkToFile(chunk: [String], index: Int) throws -> URL {
let tempDirectory = FileManager.default.temporaryDirectory
let chunkURL = tempDirectory.appendingPathComponent("\(url.lastPathComponent)-\(index).csv")
let chunkData = chunk.joined(separator: "\n")
try chunkData.write(to: chunkURL, atomically: true, encoding: .utf8)
return chunkURL
}
}
/// Process all large CSV files concurrently and gather all mini CSVs.
func chunkAllSources(sources: [CSVParser], size: Int) async throws -> [CSVParser] {
var allChunks: [CSVParser] = []
await withTaskGroup(of: [CSVParser].self) { group in
for source in sources {
group.addTask {
return (try? await source.getChunkedParser(size: size)) ?? []
}
}
for await miniCSVs in group {
allChunks.append(contentsOf: miniCSVs)
}
}
return allChunks
}

@ -7,6 +7,7 @@
import Foundation
import TipKit
import PadelClubData
struct PadelBeachExportTip: Tip {
var title: Text {
@ -430,7 +431,7 @@ struct CreateAccountTip: Tip {
Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte")
//todo
//Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus")
Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Jeter un oeil au site Padel Club")
Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Voir le site Padel Club")
}
enum ActionKey: String {
@ -549,6 +550,131 @@ struct TeamsExportTip: Tip {
}
}
struct TimeSlotMoveTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("Vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? {
Image(systemName: "arrow.up.arrow.down.circle")
}
}
struct TimeSlotMoveOptionTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("En cliquant ici, vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? {
Image(systemName: "sparkles")
}
}
struct PlayerTournamentSearchTip: Tip {
var title: Text {
Text("Cherchez un tournoi autour de vous !")
}
var message: Text? {
Text("Padel Club facilite la recherche de tournois et l'inscription !")
}
var image: Image? {
Image(systemName: "trophy.circle")
}
var actions: [Action] {
Action(id: ActionKey.selectAction.rawValue, title: "Éssayer")
}
enum ActionKey: String {
case selectAction = "selectAction"
}
}
struct OnlineRegistrationTip: Tip {
var title: Text {
Text("Inscription en ligne")
}
var message: Text? {
Text("Facilitez les inscriptions à votre tournoi en activant l'inscription en ligne. Les joueurs pourront s'inscrire directement depuis l'application ou le site Padel Club.")
}
var image: Image? {
Image(systemName: "person.2.crop.square.stack")
}
var actions: [Action] {
[
Action(id: ActionKey.more.rawValue, title: "En savoir plus"),
Action(id: ActionKey.enableOnlineRegistration.rawValue, title: "Activer dans les réglages du tournoi")
]
}
enum ActionKey: String {
case more = "more"
case enableOnlineRegistration = "enableOnlineRegistration"
}
}
struct ShouldTournamentBeOverTip: Tip {
var title: Text {
Text("Clôturer le tournoi ?")
}
var message: Text? {
Text("Le dernier match est terminé depuis plus de 2 heures. Si le tournoi a été annulé pour cause de météo vous pouvez l'indiquer comme 'Annulé' dans le menu en haut à droite, si ce n'est pas le cas, saisissez les scores manquants pour clôturer automatiquement le tournoi et publier le classement final.")
}
var image: Image? {
Image(systemName: "clock.badge.questionmark")
}
var actions: [Action] {
Action(id: "tournament-status", title: "Gérer le statut du tournoi")
}
}
struct UpdatePlannedDatesTip: Tip {
var title: Text {
Text("Mettre à jour la programmation des matchs")
}
var message: Text? {
Text("Tous les matchs dans le futur verront leur dates plannifiées mis à jour dans la section Programmation du site Padel Club.")
}
var image: Image? {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90.circle")
}
}
struct MergeTournamentTip: Tip {
var title: Text {
Text("Transfert de tournois")
}
var message: Text? {
Text("Vous pouvez transferer des tournois d'un autre événement dans celui-ci.")
}
var image: Image? {
Image(systemName: "square.and.arrow.down")
}
}
struct TipStyleModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
var tint: Color?

@ -1,80 +0,0 @@
//
// URLs.swift
// PadelClub
//
// Created by Laurent Morvillier on 22/04/2024.
//
import Foundation
enum URLs: String, Identifiable {
#if DEBUG
case activationHost = "xlr.alwaysdata.net"
case main = "https://xlr.alwaysdata.net/"
case api = "https://xlr.alwaysdata.net/roads/"
#else
case activationHost = "padelclub.app"
case main = "https://padelclub.app/"
case api = "https://padelclub.app/roads/"
#endif
case subscriptions = "https://apple.co/2Th4vqI"
case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/"
//case padelClub = "https://padelclub.app"
case tenup = "https://tenup.fft.fr"
case padelCompetitionGeneralGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi2PB5LeNNTxlrS_1-REGLESGENERALESDELACOMPETITION-ANNEESPORTIVE2025.pdf"
case padelCompetitionSpecificGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi4ax5LeNNTxlsu_3-CAHIERDESCHARGESDESTOURNOIS-ANNEESPORTIVE2025.pdf"
case padelRules = "https://xlr.alwaysdata.net/static/rules/padel-rules-2024.pdf"
case restingDischarge = "https://club.fft.fr/tennisfirmidecazeville/60120370_d/data_1/pdf/fo/formlairededechargederesponsabilitetournoidepadel.pdf"
case appReview = "https://apps.apple.com/app/padel-club/id6484163558?mt=8&action=write-review"
case appDescription = "https://padelclub.app/download/"
case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn"
case appStore = "https://apps.apple.com/app/padel-club/id6484163558"
var id: String { return self.rawValue }
var url: URL {
return URL(string: self.rawValue)!
}
static func sitePage(component: String) -> String {
return "\(URLs.main.rawValue)\(component)"
}
}
enum PageLink: String, Identifiable, CaseIterable {
case teams = "Équipes"
case summons = "Convocations"
case groupStages = "Poules"
case matches = "Tournoi"
case rankings = "Classement"
case broadcast = "Mode TV (Tournoi)"
case clubBroadcast = "Mode TV (Club)"
var id: String { self.rawValue }
func localizedLabel() -> String {
rawValue
}
var path: String {
switch self {
case .matches:
return ""
case .teams:
return "teams"
case .summons:
return "summons"
case .rankings:
return "rankings"
case .groupStages:
return "group-stages"
case .broadcast:
return "broadcast"
case .clubBroadcast:
return ""
}
}
}

@ -0,0 +1,33 @@
//
// VersionComparator.swift
// PadelClub
//
// Created by Laurent Morvillier on 13/02/2025.
//
class VersionComparator {
static func compare(_ version1: String, _ version2: String) -> Int {
// Split versions into components
let v1Components = version1.split(separator: ".").map { Int($0) ?? 0 }
let v2Components = version2.split(separator: ".").map { Int($0) ?? 0 }
// Get the maximum length to compare
let maxLength = max(v1Components.count, v2Components.count)
// Compare each component
for i in 0..<maxLength {
let v1Num = i < v1Components.count ? v1Components[i] : 0
let v2Num = i < v2Components.count ? v2Components[i] : 0
if v1Num < v2Num {
return -1 // version1 is smaller
} else if v1Num > v2Num {
return 1 // version1 is larger
}
}
return 0 // versions are equal
}
}

@ -7,6 +7,8 @@
import Foundation
import SwiftUI
import TipKit
import PadelClubData
enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var id: Int { self.rawValue }
@ -18,20 +20,27 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
case activity
case history
case tenup
enum ViewStyle {
case list
case calendar
}
case around
var localizedTitleKey: String {
switch self {
case .activity:
return "En cours"
return "À venir"
case .history:
return "Terminé"
case .tenup:
return "Tenup"
case .around:
return "Autour"
}
}
func associatedTip() -> (any Tip)? {
switch self {
case .around:
return nil //PlayerTournamentSearchTip()
default:
return nil
}
}
@ -39,25 +48,26 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
localizedTitleKey
}
var systemImage: String {
func systemImage() -> String? {
switch self {
case .activity:
return "squares.leading.rectangle"
case .history:
return "book.closed"
case .tenup:
return "tennisball"
case .around:
return "location.magnifyingglass"
default:
return nil
}
}
func badgeValue() -> Int? {
switch self {
case .activity:
DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count
case .history:
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count
case .tenup:
FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+)
case .around:
nil
}
}
@ -84,6 +94,25 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
} else {
return nil
}
case .around:
return nil
}
}
}
enum ViewStyle {
case list
case calendar
}
struct ViewStyleKey: EnvironmentKey {
static let defaultValue: ViewStyle = .list
}
extension EnvironmentValues {
var viewStyle: ViewStyle {
get { self[ViewStyleKey.self] }
set { self[ViewStyleKey.self] = newValue }
}
}

@ -7,32 +7,54 @@
import SwiftUI
import LeStorage
import PadelClubData
@Observable
class FederalDataViewModel {
static let shared = FederalDataViewModel()
var federalTournaments: [FederalTournament] = []
var searchedFederalTournaments: [FederalTournament] = []
var levels: Set<TournamentLevel> = Set()
var categories: Set<TournamentCategory> = Set()
var ageCategories: Set<FederalTournamentAge> = Set()
var selectedClubs: Set<String> = Set()
var id: UUID = UUID()
var searchAttemptCount: Int = 0
var dayDuration: Int?
var dayPeriod: DayPeriod = .all
var weekdays: Set<Int> = Set()
var lastError: NetworkManagerError?
func filterStatus() -> String {
var labels: [String] = []
labels.append(contentsOf: levels.map { $0.localizedLabel() })
labels.append(contentsOf: categories.map { $0.localizedLabel() })
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() })
labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList())
labels.append(contentsOf: categories.map { $0.localizedCategoryLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedFederalAgeLabel() }.formatList())
let clubNames = selectedClubs.compactMap { codeClub in
let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub })
return club?.clubTitle(.short)
}
labels.append(contentsOf: clubNames)
labels.append(contentsOf: clubNames.formatList())
labels.append(contentsOf: weekdays.map { Date.weekdays[$0 - 1] }.formatList())
if dayPeriod != .all {
labels.append(dayPeriod.localizedDayPeriodLabel())
}
if let dayDuration {
labels.append("max " + dayDuration.formatted() + " jour" + dayDuration.pluralSuffix)
}
return labels.joined(separator: ", ")
}
var searchedClubs: [FederalClub] {
searchedFederalTournaments.compactMap { ft in
ft.federalClub
}.uniqued { fc in
fc.federalClubCode
}.sorted(by: \.federalClubName)
}
func selectedClub() -> Club? {
if selectedClubs.isEmpty == false {
return DataStore.shared.clubs.first(where: { $0.code == selectedClubs.first! })
@ -46,25 +68,71 @@ class FederalDataViewModel {
categories.removeAll()
ageCategories.removeAll()
selectedClubs.removeAll()
dayPeriod = .all
dayDuration = nil
weekdays.removeAll()
id = UUID()
}
func areFiltersEnabled() -> Bool {
(levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty) == false
(weekdays.isEmpty && levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false
}
var filteredFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: federalTournaments)
}
var filteredFederalTournaments: [FederalTournament] {
federalTournaments.filter({ tournament in
var filteredSearchedFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: searchedFederalTournaments)
}
func filteredFederalTournaments(from tournaments: [any FederalTournamentHolder]) -> [FederalTournamentHolder] {
tournaments.filter({ tournament in
(levels.isEmpty || tournament.tournaments.anySatisfy({ levels.contains($0.level) }))
&&
(categories.isEmpty || tournament.tournaments.anySatisfy({ categories.contains($0.category) }))
&&
(ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) }))
&&
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
})
}
func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int {
tournaments.filter({ tournament in
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
})
.flatMap { $0.tournaments }
.filter {
(levels.isEmpty || levels.contains($0.level))
&&
(categories.isEmpty || categories.contains($0.category))
&&
(ageCategories.isEmpty || ageCategories.contains($0.age))
}
.count
}
func buildIsValid(_ build: any TournamentBuildHolder) -> Bool {
(levels.isEmpty || levels.contains(build.level))
&&
(categories.isEmpty || categories.contains(build.category))
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
}
func isTournamentValidForFilters(_ tournament: Tournament) -> Bool {
if tournament.isDeleted { return false }
let firstPart = (levels.isEmpty || levels.contains(tournament.level))
@ -72,6 +140,12 @@ class FederalDataViewModel {
(categories.isEmpty || categories.contains(tournament.category))
&&
(ageCategories.isEmpty || ageCategories.contains(tournament.age))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
if let codeClub = tournament.club()?.code {
return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub))
@ -87,19 +161,59 @@ class FederalDataViewModel {
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
&&
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
}
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
// Use actor or lock to safely collect results from concurrent operations
actor TournamentCollector {
private var federalTournaments: [FederalTournament] = []
func add(tournaments: [FederalTournament]) {
self.federalTournaments.append(contentsOf: tournaments)
}
func getAllTournaments() -> [FederalTournament] {
return federalTournaments
}
}
let collector = TournamentCollector()
try await clubs.filter { $0.code != nil }.concurrentForEach { club in
let newTournaments = try await NetworkFederalService.shared.getClubFederalTournaments(page: 0, tournaments: [], club: club.name, codeClub: club.code!, startDate: startDate, endDate: endDate)
let newTournaments = try await FederalDataService.shared.getClubFederalTournaments(
page: 0,
tournaments: [],
club: club.name,
codeClub: club.code!,
startDate: startDate,
endDate: endDate
)
newTournaments.forEach { tournament in
if self.federalTournaments.contains(where: { $0.id == tournament.id }) == false {
self.federalTournaments.append(tournament)
// Safely add to collector
await collector.add(tournaments: newTournaments.tournaments)
}
// Get all collected tournaments
let allNewTournaments = await collector.getAllTournaments()
// Now safely update the main array with unique items
for tournament in allNewTournaments {
if !self.federalTournaments.contains(where: { $0.id == tournament.id }) {
self.federalTournaments.append(tournament)
}
}
}
}
struct FederalClub: Identifiable {
var id: String { federalClubCode }
var federalClubCode: String
var federalClubName: String
}

@ -1,101 +0,0 @@
//
// MatchDescriptor.swift
// PadelClub
//
// Created by Razmig Sarkissian on 02/04/2024.
//
import Foundation
class MatchDescriptor: ObservableObject {
@Published var matchFormat: MatchFormat
@Published var setDescriptors: [SetDescriptor]
var court: Int = 1
var title: String = "Titre du match"
var teamLabelOne: String = ""
var teamLabelTwo: String = ""
var startDate: Date = Date()
var match: Match?
init(match: Match? = nil) {
self.match = match
if let groupStage = match?.groupStageObject {
self.matchFormat = groupStage.matchFormat
self.setDescriptors = [SetDescriptor(setFormat: groupStage.matchFormat.setFormat)]
} else {
let format = match?.matchFormat ?? match?.currentTournament()?.matchFormat ?? .defaultFormatForMatchType(.groupStage)
self.matchFormat = format
self.setDescriptors = [SetDescriptor(setFormat: format.setFormat)]
}
let teamOne = match?.team(.one)
let teamTwo = match?.team(.two)
self.teamLabelOne = teamOne?.teamLabel(.wide, twoLines: true) ?? ""
self.teamLabelTwo = teamTwo?.teamLabel(.wide, twoLines: true) ?? ""
if let match, let scoresTeamOne = match.teamScore(ofTeam: teamOne)?.score, let scoresTeamTwo = match.teamScore(ofTeam: teamTwo)?.score {
self.setDescriptors = combineArraysIntoTuples(scoresTeamOne.components(separatedBy: ","), scoresTeamTwo.components(separatedBy: ",")).map({ (a:String?, b:String?) in
SetDescriptor(valueTeamOne: a != nil ? Int(a!) : nil, valueTeamTwo: b != nil ? Int(b!) : nil, setFormat: match.matchFormat.setFormat)
})
}
}
var teamOneScores: [String] {
setDescriptors.compactMap { $0.valueTeamOne }.map { "\($0)" }
}
var teamTwoScores: [String] {
setDescriptors.compactMap { $0.valueTeamTwo }.map { "\($0)" }
}
var scoreTeamOne: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .one }.count }
var scoreTeamTwo: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .two }.count }
var hasEnded: Bool {
return matchFormat.hasEnded(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo)
}
func addNewSet() {
if hasEnded == false {
setDescriptors.append(SetDescriptor(setFormat: matchFormat.newSetFormat(setCount: setDescriptors.count)))
}
}
var winner: TeamPosition {
matchFormat.winner(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo)
}
var winnerLabel: String {
if winner == .one {
return teamLabelOne
} else {
return teamLabelTwo
}
}
}
fileprivate func combineArraysIntoTuples(_ array1: [String], _ array2: [String]) -> [(String?, String?)] {
// Zip the two arrays together and map them to tuples of optional strings
let combined = zip(array1, array2).map { (element1, element2) in
return (element1, element2)
}
// If one array is longer than the other, append the remaining elements
let remainingElements: [(String?, String?)]
if array1.count > array2.count {
let remaining = Array(array1[array2.count...]).map { (element) in
return (element, nil as String?)
}
remainingElements = remaining
} else if array2.count > array1.count {
let remaining = Array(array2[array1.count...]).map { (element) in
return (nil as String?, element)
}
remainingElements = remaining
} else {
remainingElements = []
}
// Concatenate the two arrays
return combined + remainingElements
}

@ -0,0 +1,51 @@
//
// MatchViewStyle.swift
// PadelClub
//
// Created by razmig on 17/11/2024.
//
import SwiftUI
enum MatchViewStyle {
case standardStyle // vue normal
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche
case feedStyle // vue programmation
case plainStyle // vue detail
//case tournamentResultStyle //vue resultat tournoi
case followUpStyle // vue normal
func displayRestingTime() -> Bool {
switch self {
case .standardStyle:
return false
case .sectionedStandardStyle:
return false
case .feedStyle:
return false
case .plainStyle:
return false
// case .tournamentResultStyle:
// return false
case .followUpStyle:
return true
}
}
}
struct MatchViewStyleKey: EnvironmentKey {
static let defaultValue: MatchViewStyle = .standardStyle
}
extension EnvironmentValues {
var matchViewStyle: MatchViewStyle {
get { self[MatchViewStyleKey.self] }
set { self[MatchViewStyleKey.self] = newValue }
}
}
extension View {
func matchViewStyle(_ style: MatchViewStyle) -> some View {
environment(\.matchViewStyle, style)
}
}

@ -6,12 +6,13 @@
//
import SwiftUI
import PadelClubData
@Observable
class NavigationViewModel {
var path = NavigationPath()
var toolboxPath = NavigationPath()
var umpirePath: [UmpireView.UmpireScreen] = []
var accountPath: [MyAccountView.AccountScreen] = []
var ongoingPath = NavigationPath()
var selectedTab: TabDestination?
var agendaDestination: AgendaDestination? = .activity

@ -6,6 +6,7 @@
//
import SwiftUI
import PadelClubData
class DebouncableViewModel: ObservableObject {
@Published var debouncableText: String = ""
@ -35,8 +36,8 @@ class SearchViewModel: ObservableObject, Identifiable {
@Published var selectedPlayers: Set<ImportedPlayer> = Set()
@Published var filterSelectionEnabled: Bool = false
@Published var isPresented: Bool = false
var mostRecentDate: Date? = nil
@Published var selectedAgeCategory: FederalTournamentAge = .unlisted
@Published var mostRecentDate: Date? = nil
var selectionIsOver: Bool {
if allowSingleSelection && selectedPlayers.count == 1 {
@ -67,11 +68,23 @@ class SearchViewModel: ObservableObject, Identifiable {
var contentUnavailableMessage: String {
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."]
if tokens.isEmpty {
message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois. Dans ce pas, Padel Club ne pourra pas le trouver.")
message.append(
"Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver."
)
}
return message.joined(separator: "\n")
}
func sortTitle() -> String {
var base = [sortOption.localizedLabel()]
base.append((ascending ? "croissant" : "décroissant"))
if selectedAgeCategory != .unlisted {
base.append(selectedAgeCategory.localizedFederalAgeLabel())
}
return base.joined(separator: " ")
}
func codeClubs() -> [String] {
let clubs: [Club] = DataStore.shared.user.clubsObjects()
return clubs.compactMap { $0.code }
@ -79,22 +92,59 @@ class SearchViewModel: ObservableObject, Identifiable {
func getCodeClub() -> String? {
if let codeClub { return codeClub }
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode { return userCodeClub }
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode {
return userCodeClub
}
return nil
}
func getLigueName() -> String? {
if let ligueName { return ligueName }
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName { return userLigueName }
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName {
return userLigueName
}
return nil
}
func shouldIncludeSearchTextPredicate() -> Bool {
if allowMultipleSelection {
return true
}
if allowSingleSelection {
return true
}
if tokens.isEmpty == false || hideAssimilation || selectedAgeCategory != .unlisted {
return true
}
return dataSet == .national && searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
}
func showIndex() -> Bool {
if (dataSet == .national || dataSet == .ligue) { return false }
if filterOption == .all { return false }
if dataSet == .national {
if searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
{
return false
} else {
return isFiltering()
}
}
if dataSet == .ligue { return isFiltering() }
if filterOption == .all { return isFiltering() }
return true
}
func isFiltering() -> Bool {
searchText.isEmpty == false || tokens.isEmpty == false || hideAssimilation
|| selectedAgeCategory != .unlisted
}
func prompt(forDataSet: DataSet) -> String {
switch forDataSet {
case .national:
@ -128,100 +178,240 @@ class SearchViewModel: ObservableObject, Identifiable {
}
func words() -> [String] {
return searchText.canonicalVersionWithPunctuation.trimmed.components(separatedBy: .whitespaces)
let cleanedText = searchText.cleanSearchText().canonicalVersionWithPunctuation.trimmed
return cleanedText.components(
separatedBy: .whitespaces)
}
func wordsPredicates() -> NSPredicate? {
let words = words().filter({ $0.isEmpty == false })
// Handle special case of hyphenated words
let hyphenatedWords = searchText.components(separatedBy: .whitespaces)
.filter { $0.contains("-") }
var predicates: [NSPredicate] = []
// Add predicates for hyphenated words
for word in hyphenatedWords {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", word))
let parts = word.components(separatedBy: "-")
for part in parts where part.count > 1 {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
}
}
// Regular words processing
switch words.count {
case 2:
let predicates = [
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[0], words[1]),
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[1], words[0]),
]
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
predicates.append(contentsOf: [
NSPredicate(
format:
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@",
words[0], words[1]),
NSPredicate(
format:
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@",
words[1], words[0]),
// For multi-word first names, try the two words as a first name
NSPredicate(
format: "canonicalFirstName CONTAINS[cd] %@", words.joined(separator: " ")),
])
case 3:
// Handle potential cases like "Jean Christophe CROS"
predicates.append(contentsOf: [
// First two words as first name, last as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0] + " " + words[1], words[2]),
// First as first name, last two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0], words[1] + " " + words[2]),
// Last as first name, first two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[2], words[0] + " " + words[1]),
])
default:
return nil
if words.count > 0 {
// For single word or many words, try matching against full name
predicates.append(
NSPredicate(
format: "canonicalFullName CONTAINS[cd] %@",
words.joined(separator: " ")))
}
}
func orPredicate() -> NSPredicate? {
return predicates.isEmpty
? nil : NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func searchTextPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union(
CharacterSet(charactersIn: "-"))
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation
switch tokens.first {
case .none:
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
// Check for license numbers
let matches = canonicalVersionWithoutPunctuation.licencesFound()
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: licensesPredicates))
if canonicalVersionWithoutPunctuation.isEmpty == false {
let wordsPredicates = wordsPredicates()
if let wordsPredicates {
predicates.append(wordsPredicates)
} else {
predicates.append(NSPredicate(format: "license contains[cd] %@", canonicalVersionWithoutPunctuation))
}
predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation))
let components = canonicalVersionWithoutPunctuation.split(separator: " ").sorted()
// Add match for full name
predicates.append(
NSPredicate(
format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
// Add pattern match for more flexible matching
let components = canonicalVersionWithoutPunctuation.split(separator: " ")
let pattern = components.joined(separator: ".*")
let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
predicates.append(predicate)
predicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
// // Look for exact matches on first or last name
// let words = canonicalVersionWithoutPunctuation.components(separatedBy: .whitespaces)
// for word in words where word.count > 2 {
// predicates.append(
// NSPredicate(
// format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", word, word)
// )
// }
}
if predicates.isEmpty {
return nil
}
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func orPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union(
CharacterSet(charactersIn: "-"))
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed
if tokens.isEmpty {
if shouldIncludeSearchTextPredicate(),
canonicalVersionWithoutPunctuation.isEmpty == false
{
if let searchTextPredicate = searchTextPredicate() {
predicates.append(searchTextPredicate)
}
}
}
// Process tokens
for token in tokens {
switch token {
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(NSPredicate(format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation))
predicates.append(
NSPredicate(
format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(NSPredicate(format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
predicates.append(
NSPredicate(
format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
predicates.append(
NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
predicates.append(
NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
predicates.append(
NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
case .age:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "birthYear == 0"))
} else if let birthYear = Int(canonicalVersionWithoutPunctuation) {
predicates.append(
NSPredicate(format: "birthYear == %@", birthYear.formattedAsRawString()))
}
}
}
if predicates.isEmpty {
return nil
}
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
let full = NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
return full
}
func predicate() -> NSPredicate? {
var predicates: [NSPredicate?] = [
orPredicate(),
filterOption == .male ?
NSPredicate(format: "male == YES") :
nil,
filterOption == .female ?
NSPredicate(format: "male == NO") :
nil,
filterOption == .male ? NSPredicate(format: "male == YES") : nil,
filterOption == .female ? NSPredicate(format: "male == NO") : nil,
]
if let mostRecentDate {
//predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
}
if hideAssimilation {
predicates.append(NSPredicate(format: "assimilation == %@", "Non"))
}
if selectedAgeCategory != .unlisted {
let computedBirthYear = selectedAgeCategory.computedBirthYear()
if let left = computedBirthYear.0 {
predicates.append(
NSPredicate(format: "birthYear >= %@", left.formattedAsRawString()))
}
if let right = computedBirthYear.1 {
predicates.append(
NSPredicate(format: "birthYear <= %@", right.formattedAsRawString()))
}
}
switch dataSet {
case .national:
break
@ -251,7 +441,6 @@ class SearchViewModel: ObservableObject, Identifiable {
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 }))
}
func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] {
sortOption.sortDescriptors(ascending, dataSet: dataSet)
}
@ -259,6 +448,192 @@ class SearchViewModel: ObservableObject, Identifiable {
func nsSortDescriptors() -> [NSSortDescriptor] {
sortDescriptors().map { NSSortDescriptor($0) }
}
static func getSpecialSlashPredicate(inputString: String) -> NSPredicate? {
// Define a regular expression to find slashes between alphabetic characters (not digits)
print(inputString)
let cleanedInput = inputString.cleanSearchText()
let pattern = /(\b[A-Za-z]+)\s*\/\s*([A-Za-z]+\b)/
// Find matches in the input string
guard let match = cleanedInput.firstMatch(of: pattern) else {
print("No valid name pairs found")
return nil
}
let lastName1 = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines)
let lastName2 = match.output.2.trimmingCharacters(in: .whitespacesAndNewlines)
// Ensure both names are not empty
guard !lastName1.isEmpty && !lastName2.isEmpty else {
print("One or both names are empty")
return nil
}
// Create the NSPredicate for searching in the `lastName` field
let predicate = NSPredicate(
format: "lastName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", lastName1, lastName2)
// Output the result
//print("Generated Predicate: \(predicate)")
return predicate
}
static func pastePredicate(
pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption
) -> NSPredicate? {
print("🔍 pastePredicate called with: \(pasteField)")
print("📅 mostRecentDate: \(String(describing: mostRecentDate))")
print("🔍 filterOption: \(filterOption)")
var andPredicates = [NSPredicate]()
var orPredicates = [NSPredicate]()
// Check for license numbers
let matches = pasteField.licencesFound()
print("🎫 Licenses found: \(matches)")
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
orPredicates = licensesPredicates
if matches.count == 2 {
print("✅ Returning early with 2 license predicates")
return NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)
}
// Add gender filter if specified
if filterOption == .female {
print("👩 Adding female filter")
andPredicates.append(NSPredicate(format: "male == NO"))
} else if filterOption == .male {
print("👨 Adding male filter")
andPredicates.append(NSPredicate(format: "male == YES"))
}
// Add date filter if specified
if let mostRecentDate {
print("📆 Adding date filter for: \(mostRecentDate)")
andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
}
// Check for slashes (representing alternatives)
if let slashPredicate = getSpecialSlashPredicate(inputString: pasteField) {
print("🔀 Found slash predicate")
orPredicates.append(slashPredicate)
}
// Prepare text for processing - preserve hyphens but remove digits
var text =
pasteField
.replacingOccurrences(of: "[\\(\\)\\[\\]\\{\\}]", with: "", options: .regularExpression)
.replacingOccurrences(of: "/", with: " ") // Replace slashes with spaces
.replacingOccurrences(of: "[\\.,;:!?]", with: " ", options: .regularExpression) // Replace common punctuation with spaces
.trimmingCharacters(in: .whitespacesAndNewlines)
print("🧹 Cleaned text: \"\(text)\"")
// Remove digits
let digitPattern = /\b\w*\d\w*\b/
text = text.replacing(digitPattern, with: "").trimmingCharacters(
in: .whitespacesAndNewlines)
print("🔢 After digit removal: \"\(text)\"")
// Split text by whitespace to get potential name components
let textComponents = text.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter {
!$0.isEmpty && $0.count > 1 && !["de", "la", "le", "du", "et", "si"].contains($0.lowercased())
}
print("📚 Text components: \(textComponents)")
if textComponents.count < 50 {
print("✓ Text components count is reasonable: \(textComponents.count)")
// Handle exact fullname match
let fullName = textComponents.joined(separator: " ")
if !fullName.isEmpty {
print("📝 Adding predicate for full name: \"\(fullName)\"")
orPredicates.append(
NSPredicate(format: "canonicalFullName CONTAINS[cd] %@", fullName))
}
// Handle hyphenated last names
let hyphenatedComponents = textComponents.filter { $0.contains("-") }
print("🔗 Hyphenated components: \(hyphenatedComponents)")
for component in hyphenatedComponents {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", component))
print("➕ Added hyphenated last name predicate: \"\(component)\"")
// Also search for each part of the hyphenated name
let parts = component.components(separatedBy: "-")
for part in parts {
if part.count > 1 {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
print("➕ Added hyphenated part predicate: \"\(part)\"")
}
}
}
// Try different combinations for first/last name
if textComponents.count > 1 {
print("🔄 Creating combinations with \(textComponents.count) components")
// Try each pair of components as first+last and last+first
for i in 0..<textComponents.count-1 {
let j = i + 1
print("👥 Trying adjacent pair: \"\(textComponents[i])\" and \"\(textComponents[j])\"")
// orPredicates.append(
// NSPredicate(
// format:
// "(firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@) OR (firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@)",
// textComponents[i], textComponents[j], textComponents[j],
// textComponents[i]
// ))
// Also try beginswith for more precise matches
orPredicates.append(
NSPredicate(
format:
"(firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@) OR (firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@)",
textComponents[i], textComponents[j], textComponents[j],
textComponents[i]
))
}
} else if textComponents.count == 1 {
print("👤 Single component search: \"\(textComponents[0])\"")
// If only one component, search in both first and last name
orPredicates.append(
NSPredicate(
format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@",
textComponents[0], textComponents[0]))
}
// Add pattern match for canonical full name
// let pattern = textComponents.joined(separator: ".*")
// print("🔍 Adding pattern match: \"\(pattern)\"")
// orPredicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
} else {
print(" Too many text components: \(textComponents.count) - skipping name combinations")
}
// Construct final predicate
var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates)
print("📊 AND predicates count: \(andPredicates.count)")
if !orPredicates.isEmpty {
print("📊 OR predicates count: \(orPredicates.count)")
let orCompoundPredicate = NSCompoundPredicate(
orPredicateWithSubpredicates: orPredicates)
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate, orCompoundPredicate,
])
}
print("🏁 Final predicate created")
print(predicate)
return predicate
}
}
enum SearchToken: String, CaseIterable, Identifiable {
@ -267,6 +642,7 @@ enum SearchToken: String, CaseIterable, Identifiable {
case rankMoreThan = "rang >"
case rankLessThan = "rang <"
case rankBetween = "rang <>"
case age = "âge sportif"
var id: String {
rawValue
@ -275,15 +651,22 @@ enum SearchToken: String, CaseIterable, Identifiable {
var message: String {
switch self {
case .club:
return "Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
return
"Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .ligue:
return "Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
return
"Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .rankMoreThan:
return "Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
return
"Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
case .rankLessThan:
return "Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
return
"Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
case .rankBetween:
return "Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
return
"Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
case .age:
return "Taper une année de naissance"
}
}
@ -297,6 +680,8 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Chercher un rang"
case .rankBetween:
return "Chercher une intervalle de classement"
case .age:
return "Chercher une année de naissance"
}
}
@ -312,6 +697,8 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Rang inférieur ou égale à"
case .rankBetween:
return "Rang entre deux valeurs"
case .age:
return "Année de naissance"
}
}
@ -327,6 +714,8 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Rang ≤"
case .rankBetween:
return "Rang ≥,≤"
case .age:
return "Né(e) en"
}
}
@ -342,6 +731,8 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
@ -357,6 +748,8 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
}
@ -385,53 +778,128 @@ enum DataSet: Int, Identifiable {
}
var tokens: [SearchToken] {
var _tokens: [SearchToken] = []
switch self {
case .national:
return [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
_tokens = [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
case .ligue:
return [.club, .rankMoreThan, .rankLessThan, .rankBetween]
_tokens = [.club, .rankMoreThan, .rankLessThan, .rankBetween]
case .club:
return [.rankMoreThan, .rankLessThan, .rankBetween]
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
case .favoritePlayers, .favoriteClubs:
return [.rankMoreThan, .rankLessThan, .rankBetween]
}
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
}
}
enum SortOption: Int, CaseIterable, Identifiable {
case name
case rank
case tournamentCount
case points
var id: Int { self.rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .name:
return "Nom"
case .rank:
return "Rang"
case .tournamentCount:
return "Tournoi"
case .points:
return "Points"
_tokens.append(.age)
return _tokens
}
}
extension SortOption {
func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
switch self {
case .name:
return [SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation)]
return [
SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
]
case .rank:
if (dataSet == .national || dataSet == .ligue) {
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)]
if dataSet == .national || dataSet == .ligue {
return [
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
]
} else {
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
return [
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
}
case .tournamentCount:
return [SortDescriptor(\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
return [
SortDescriptor(
\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
case .points:
return [SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
return [
SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
case .progression:
return [
SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
}
}
}
//enum SortOption: Int, CaseIterable, Identifiable {
// case name
// case rank
// case tournamentCount
// case points
// case progression
//
// var id: Int { self.rawValue }
// func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
// switch self {
// case .name:
// return "Nom"
// case .rank:
// return "Rang"
// case .tournamentCount:
// return "Tournoi"
// case .points:
// return "Points"
// case .progression:
// return "Progression"
// }
// }
//
// func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
// switch self {
// case .name:
// return [
// SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// ]
// case .rank:
// if dataSet == .national || dataSet == .ligue {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
// ]
// } else {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// case .tournamentCount:
// return [
// SortDescriptor(
// \ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .points:
// return [
// SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .progression:
// return [
// SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// }
//}

@ -1,51 +0,0 @@
//
// Selectable.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/04/2024.
//
import Foundation
import SwiftUI
protocol Selectable {
func selectionLabel(index: Int) -> String
func badgeValue() -> Int?
func badgeImage() -> Badge?
func badgeValueColor() -> Color?
func displayImageIfValueZero() -> Bool
}
extension Selectable {
func displayImageIfValueZero() -> Bool {
return false
}
}
enum Badge {
case checkmark
case xmark
case custom(systemName: String, color: Color)
func systemName() -> String {
switch self {
case .checkmark:
return "checkmark.circle.fill"
case .xmark:
return "xmark.circle.fill"
case .custom(let systemName, _):
return systemName
}
}
func color() -> Color {
switch self {
case .checkmark:
.green
case .xmark:
.logoRed
case .custom(_, let color):
color
}
}
}

@ -1,33 +0,0 @@
//
// SetDescriptor.swift
// PadelClub
//
// Created by Razmig Sarkissian on 02/04/2024.
//
import Foundation
struct SetDescriptor: Identifiable, Equatable {
let id: UUID = UUID()
var valueTeamOne: Int?
var valueTeamTwo: Int?
var tieBreakValueTeamOne: Int?
var tieBreakValueTeamTwo: Int?
var setFormat: SetFormat
var hasEnded: Bool {
if let valueTeamTwo, let valueTeamOne {
return setFormat.hasEnded(teamOne: valueTeamOne, teamTwo: valueTeamTwo)
} else {
return false
}
}
var winner: TeamPosition? {
if let valueTeamTwo, let valueTeamOne {
return setFormat.winner(teamOne: valueTeamOne, teamTwo: valueTeamTwo)
} else {
return nil
}
}
}

@ -17,6 +17,7 @@ enum TabDestination: CaseIterable, Identifiable {
case tournamentOrganizer
case umpire
case ongoing
case myAccount
var title: String {
switch self {
@ -30,6 +31,8 @@ enum TabDestination: CaseIterable, Identifiable {
return "Gestionnaire"
case .umpire:
return "Juge-Arbitre"
case .myAccount:
return "Compte"
}
}
@ -45,6 +48,8 @@ enum TabDestination: CaseIterable, Identifiable {
return "squares.below.rectangle"
case .umpire:
return "person.bust"
case .myAccount:
return "person.crop.circle"
}
}
}

@ -0,0 +1,215 @@
//
// BracketCallingView.swift
// PadelClub
//
// Created by razmig on 15/10/2024.
//
import SwiftUI
import LeStorage
import PadelClubData
struct BracketCallingView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(Tournament.self) var tournament: Tournament
@State private var initialSeedRound: Int = 0
@State private var initialSeedCount: Int = 0
let tournamentRounds: [Round]
let teams: [TeamRegistration]
init(tournament: Tournament) {
let rounds = tournament.rounds()
self.tournamentRounds = rounds
self.teams = tournament.availableSeeds()
if tournament.initialSeedRound == 0, rounds.count > 0 {
let index = rounds.count - 1
_initialSeedRound = .init(wrappedValue: index)
_initialSeedCount = .init(wrappedValue: RoundRule.numberOfMatches(forRoundIndex: index))
} else if tournament.initialSeedRound < rounds.count {
_initialSeedRound = .init(wrappedValue: tournament.initialSeedRound)
_initialSeedCount = .init(wrappedValue: tournament.initialSeedCount)
} else if rounds.count > 0 {
let index = rounds.count - 1
_initialSeedRound = .init(wrappedValue: index)
_initialSeedCount = .init(wrappedValue: RoundRule.numberOfMatches(forRoundIndex: index))
}
}
var initialRound: Round {
tournamentRounds.first(where: { $0.index == initialSeedRound })!
}
func filteredRounds() -> [Round] {
tournamentRounds.filter({ $0.index >= initialSeedRound }).reversed()
}
func seedCount(forRoundIndex roundIndex: Int) -> Int {
if roundIndex < initialSeedRound { return 0 }
if roundIndex == initialSeedRound {
return initialSeedCount
}
let seedCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex)
let previousSeedCount = self.seedCount(forRoundIndex: roundIndex - 1)
let total = seedCount - previousSeedCount
if total < 0 { return 0 }
return total
}
func seeds(forRoundIndex roundIndex: Int) -> [TeamRegistration] {
let previousSeeds: Int = (initialSeedRound..<roundIndex).map { seedCount(forRoundIndex: $0) }.reduce(0, +)
if roundIndex == tournamentRounds.count - 1 {
return Array(teams.dropFirst(previousSeeds))
} else {
return Array(teams.dropFirst(previousSeeds).prefix(seedCount(forRoundIndex: roundIndex)))
}
}
var body: some View {
List {
let uncalledTeams = teams.filter({ $0.callDate == nil })
if uncalledTeams.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalledTeams.count.pluralSuffix) non contactée\(uncalledTeams.count.pluralSuffix)", value: uncalledTeams.count.formatted())
}
}
PlayersWithoutContactView(players: teams.flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
Section {
Picker(selection: $initialSeedRound) {
ForEach(tournamentRounds) {
Text($0.roundTitle()).tag($0.index)
}
} label: {
Text("Premier tour")
}
.onChange(of: initialSeedRound) {
initialSeedCount = RoundRule.numberOfMatches(forRoundIndex: initialSeedRound)
}
LabeledContent {
StepperView(count: $initialSeedCount, minimum: 0, maximum: RoundRule.numberOfMatches(forRoundIndex: initialSeedRound))
} label: {
Text("Têtes de série")
}
} footer: {
Text("Permet de convoquer par tour du tableau sans avoir tirer au sort les tétes de série. Vous pourrez ensuite confirmer leur horaire plus précis si le tour se joue sur plusieurs rotations. Les équipes ne peuvent pas être considéré comme convoqué au bon horaire en dehors de cet écran tant qu'elles n'ont pas été placé dans le tableau.")
}
ForEach(filteredRounds()) { round in
let seeds = seeds(forRoundIndex: round.index)
let startDate = ([round.startDate] + round.playedMatches().map { $0.startDate }).compacted().min()
let callSeeds = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0, expectedSummonDate: startDate) == false })
if seeds.isEmpty == false {
Section {
NavigationLink {
_roundView(round: round, seeds: seeds)
.environment(tournament)
} label: {
CallView.CallStatusView(count: callSeeds.count, total: seeds.count, startDate: startDate, title: "convoquées")
}
} header: {
Text(round.roundTitle())
} footer: {
if let startDate {
CallView(teams: seeds, callDate: startDate, matchFormat: round.matchFormat, roundLabel: round.roundTitle())
}
}
}
}
}
.onDisappear(perform: {
tournament.initialSeedCount = initialSeedCount
tournament.initialSeedRound = initialSeedRound
_save()
})
.headerProminence(.increased)
.navigationTitle("Pré-convocation")
}
private func _save() {
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
@ViewBuilder
private func _roundView(round: Round, seeds: [TeamRegistration]) -> some View {
List {
let uncalledTeams = seeds.filter({ $0.callDate == nil })
if uncalledTeams.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalledTeams.count.pluralSuffix) non contactée\(uncalledTeams.count.pluralSuffix)", value: uncalledTeams.count.formatted())
}
}
let startDate = round.startDate ?? round.playedMatches().first?.startDate
let badCalled = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0, expectedSummonDate: startDate) })
if badCalled.isEmpty == false {
Section {
ForEach(badCalled) { team in
TeamCallView(team: team)
}
} header: {
HStack {
Text("Mauvais horaire")
Spacer()
Text(badCalled.count.formatted() + " équipe\(badCalled.count.pluralSuffix)")
}
} footer: {
if let startDate {
CallView(teams: badCalled, callDate: startDate, matchFormat: round.matchFormat, roundLabel: round.roundTitle())
}
}
}
Section {
ForEach(seeds) { team in
TeamCallView(team: team)
}
} header: {
HStack {
Text(round.roundTitle())
Spacer()
Text(seeds.count.formatted() + " équipe\(seeds.count.pluralSuffix)")
}
}
}
.overlay {
if seeds.isEmpty {
ContentUnavailableView {
Label("Aucune équipe dans ce tour", systemImage: "clock.badge.questionmark")
} description: {
Text("Padel Club n'a pas réussi à déterminer quelles équipes jouent ce tour.")
} actions: {
// RowButtonView("Horaire intelligent") {
// selectedScheduleDestination = nil
// }
}
}
}
.headerProminence(.increased)
.navigationTitle(round.roundTitle())
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
}
}
//#Preview {
// SeedsCallingView()
//}

@ -7,6 +7,7 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallMessageCustomizationView: View {
@EnvironmentObject var dataStore: DataStore
@ -29,8 +30,8 @@ struct CallMessageCustomizationView: View {
init(tournament: Tournament) {
self.tournament = tournament
_customCallMessageBody = State(wrappedValue: DataStore.shared.user.summonsMessageBody ?? (DataStore.shared.user.summonsUseFullCustomMessage ? "" : ContactType.defaultCustomMessage))
_customCallMessageSignature = State(wrappedValue: DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature())
_customClubName = State(wrappedValue: tournament.clubName ?? "Lieu du tournoi")
_customCallMessageSignature = State(wrappedValue: DataStore.shared.user.getSummonsMessageSignature() ?? DataStore.shared.user.defaultSignature(tournament))
_customClubName = State(wrappedValue: tournament.customClubName ?? tournament.clubName ?? "Lieu du tournoi")
_summonsAvailablePaymentMethods = State(wrappedValue: DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods)
}
@ -43,12 +44,20 @@ struct CallMessageCustomizationView: View {
}
var computedMessage: String {
[entryFeeMessage, customCallMessageBody].compacted().map { $0.trimmed }.joined(separator: "\n")
var linkMessage: String? {
if tournament.isPrivate == false, let shareLink = tournament.shareURL(.matches)?.absoluteString {
return "Vous pourrez suivre tous les résultats de ce tournoi sur le site :\n\n".appending(shareLink)
} else {
return nil
}
}
return [entryFeeMessage, customCallMessageBody, linkMessage].compacted().map { $0.trimmedMultiline }.joined(separator: "\n")
}
var finalMessage: String? {
let localizedCalled = "convoqué" + (tournament.tournamentCategory == .women ? "e" : "") + "s"
return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)"
return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.title, hideSenior: true)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)"
}
var body: some View {
@ -81,7 +90,7 @@ struct CallMessageCustomizationView: View {
}
Divider()
FooterButtonView("défaut") {
customCallMessageSignature = DataStore.shared.user.defaultSignature()
customCallMessageSignature = DataStore.shared.user.defaultSignature(tournament)
_save()
}
Divider()
@ -95,6 +104,16 @@ struct CallMessageCustomizationView: View {
}
.headerProminence(.increased)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(focusedField != nil)
.toolbar(content: {
if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
}
})
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Message de convocation")
.toolbar {
@ -107,7 +126,7 @@ struct CallMessageCustomizationView: View {
} label: {
Text("Valider")
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
}
}
}
@ -126,8 +145,16 @@ struct CallMessageCustomizationView: View {
}
private func _save() {
if customCallMessageBody.isEmpty {
self.dataStore.user.summonsMessageBody = nil
} else {
self.dataStore.user.summonsMessageBody = customCallMessageBody
}
if customCallMessageSignature.isEmpty {
self.dataStore.user.summonsMessageSignature = nil
} else {
self.dataStore.user.summonsMessageSignature = customCallMessageSignature
}
self.dataStore.user.summonsAvailablePaymentMethods = summonsAvailablePaymentMethods
self.dataStore.saveUser()
}
@ -208,14 +235,13 @@ struct CallMessageCustomizationView: View {
if let eventClub = tournament.eventObject()?.clubObject() {
let hasBeenCreated: Bool = eventClub.hasBeenCreated(by: StoreCenter.main.userId)
Section {
TextField("Nom du club", text: $customClubName, axis: .vertical)
.lineLimit(2)
TextField("Nom du club", text: $customClubName)
.autocorrectionDisabled()
.focused($focusedField, equals: .clubName)
.onSubmit {
eventClub.name = customClubName
tournament.customClubName = customClubName.prefixTrimmed(100)
do {
try dataStore.clubs.addOrUpdate(instance: eventClub)
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
@ -249,7 +275,7 @@ struct CallMessageCustomizationView: View {
}
}.italic().foregroundStyle(.gray)
} header: {
Text("Rendu généré automatiquement")
Text("Exemple généré automatiquement")
}
}

@ -7,6 +7,7 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallSettingsView: View {
@EnvironmentObject var dataStore: DataStore
@ -15,13 +16,15 @@ struct CallSettingsView: View {
@State private var showSendToAllView: Bool = false
@State private var addLink: Bool = false
var tournamentStore: TournamentStore {
var tournamentStore: TournamentStore? {
return self.tournament.tournamentStore
}
var body: some View {
List {
Section {
NavigationLink {
CallMessageCustomizationView(tournament: tournament)
@ -64,7 +67,7 @@ struct CallSettingsView: View {
team.callDate = nil
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
@ -82,7 +85,7 @@ struct CallSettingsView: View {
}
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
@ -93,11 +96,11 @@ struct CallSettingsView: View {
//#endif
}
.sheet(isPresented: $showSendToAllView) {
SendToAllView(addLink: false)
SendToAllView(tournament: tournament, addLink: false)
.tint(.master)
}
.sheet(isPresented: $addLink) {
SendToAllView(addLink: true)
SendToAllView(tournament: tournament, addLink: true)
.tint(.master)
}
}

@ -7,6 +7,7 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallView: View {
@ -14,6 +15,7 @@ struct CallView: View {
let count: Int
let total: Int
let startDate: Date?
var title: String = "convoquées au bon horaire"
var body: some View {
VStack(spacing: 0) {
@ -32,7 +34,7 @@ struct CallView: View {
Text(startDate.formatted(.dateTime.weekday().day(.twoDigits).month().year()))
}
Spacer()
Text("convoquées au bon horaire")
Text(title)
}
.font(.caption)
.foregroundColor(.secondary)
@ -40,14 +42,6 @@ struct CallView: View {
}
}
struct TeamView: View {
let team: TeamRegistration
var body: some View {
TeamRowView(team: team, displayCallDate: true)
}
}
@EnvironmentObject var dataStore: DataStore
@EnvironmentObject var networkMonitor: NetworkMonitor
@ -57,6 +51,7 @@ struct CallView: View {
let callDate: Date
let matchFormat: MatchFormat
let roundLabel: String
let displayContext: SummoningDisplayContext
@State private var contactType: ContactType? = nil
@State private var sentError: ContactManagerError? = nil
@ -65,9 +60,61 @@ struct CallView: View {
@State var showUserCreationView: Bool = false
@State var summonParamByMessage: Bool = false
@State var summonParamReSummon: Bool = false
@State var summonParamReSummon: SummonType = .contact
let simpleMode : Bool
let summonType: SummonType
var tournamentStore: TournamentStore {
init(teams: [TeamRegistration], callDate: Date, matchFormat: MatchFormat, roundLabel: String) {
self.teams = teams
self.callDate = callDate
self.matchFormat = matchFormat
self.roundLabel = roundLabel
self.simpleMode = false
self.displayContext = .footer
self.summonType = .contact
}
init(teams: [TeamRegistration]) {
self.teams = teams
self.callDate = Date()
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = true
self.displayContext = .footer
self.summonType = .contact
}
init(team: TeamRegistration, displayContext: SummoningDisplayContext, summonType: SummonType) {
self.teams = [team]
let expectedSummonDate = team.expectedSummonDate()
self.displayContext = displayContext
self.summonType = summonType
if let expectedSummonDate, let initialMatch = team.initialMatch() {
self.callDate = expectedSummonDate
self.matchFormat = initialMatch.matchFormat
self.roundLabel = initialMatch.roundTitle() ?? "tableau"
self.simpleMode = false
} else if let expectedSummonDate, let initialGroupStage = team.groupStageObject() {
self.callDate = expectedSummonDate
self.matchFormat = initialGroupStage.matchFormat
self.roundLabel = "poule"
self.simpleMode = false
} else if let expectedSummonDate {
self.callDate = expectedSummonDate
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = false
} else {
self.callDate = Date()
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = true
}
}
var tournamentStore: TournamentStore? {
return self.tournament.tournamentStore
}
@ -81,49 +128,74 @@ struct CallView: View {
}
}
private func _called(_ success: Bool) {
private func _called(_ calledTeams: [TeamRegistration], _ success: Bool) {
if simpleMode {
return
}
if success {
self.teams.forEach { team in
calledTeams.forEach { team in
team.callDate = callDate
if summonType.shouldConfirm() {
team.confirmationDate = nil
}
}
do {
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: calledTeams)
} catch {
Logger.error(error)
}
}
}
func finalMessage(reSummon: Bool) -> String {
ContactType.callingMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat, reSummon: reSummon)
func finalMessage(summonType: SummonType, forcedEmptyMessage: Bool) -> String {
if summonType == .contactWithoutSignature {
return ""
}
var reSummon: Bool {
return self.teams.allSatisfy({ $0.called() })
if simpleMode || forcedEmptyMessage {
let signature = dataStore.user.getSummonsMessageSignature() ?? dataStore.user.defaultSignature(tournament)
return "\n\n\n\n" + signature
}
var body: some View {
let callWord : String = (reSummon ? "Reconvoquer" : "Convoquer")
HStack {
if self.teams.count == 1 {
if let previousCallDate = teams.first?.callDate, Calendar.current.compare(previousCallDate, to: callDate, toGranularity: .minute) != .orderedSame {
Text("Reconvoquer \(self.callDate.localizedDate()) par")
} else {
Text("\(callWord) cette paire par")
return ContactType.callingMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat, summonType: summonType)
}
//
// var summonType: SummonType {
// if simpleMode {
// return .contact
// }
// return self.teams.allSatisfy({ $0.called() }) == true ? .summon : .summonWalkoutFollowUp
// }
var mainWord: String {
if simpleMode {
return "Contacter"
} else {
Text("\(callWord) ces \(self.teams.count) paires par")
return "Convoquer"
}
}
self._summonMenu(byMessage: true)
Text("ou")
self._summonMenu(byMessage: false)
var body: some View {
Group {
switch displayContext {
case .footer:
_footerStyleView()
case .menu:
_menuStyleView()
}
}
.font(.subheadline)
.buttonStyle(.borderless)
.alert("Un problème est survenu", isPresented: messageSentFailed) {
Button("OK") {
}
if case .uncalledTeams(let uncalledTeams) = sentError {
NavigationLink("Voir les équipes non contactées") {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
}
}
} message: {
Text(_networkErrorMessage)
}
@ -138,10 +210,22 @@ struct CallView: View {
case .failed:
self.sentError = .messageFailed
case .sent:
let calledTeams = teams.filter { $0.getPhoneNumbers().isEmpty == false }
let uncalledTeams = teams.filter { $0.getPhoneNumbers().isEmpty }
if networkMonitor.connected == false {
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .messageNotSent
}
} else {
self._called(true)
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else if uncalledTeams.isEmpty == false, calledTeams.isEmpty {
self._called(uncalledTeams, true)
}
self._called(calledTeams, true)
}
@unknown default:
break
@ -156,11 +240,23 @@ struct CallView: View {
self.contactType = nil
self.sentError = .mailFailed
case .sent:
let calledTeams = teams.filter { $0.getMail().isEmpty == false }
let uncalledTeams = teams.filter { $0.getMail().isEmpty }
if networkMonitor.connected == false {
self.contactType = nil
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .mailNotSent
}
} else {
self._called(true)
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else if uncalledTeams.isEmpty == false, calledTeams.isEmpty {
self._called(uncalledTeams, true)
}
self._called(calledTeams, true)
}
@unknown default:
break
@ -180,25 +276,99 @@ struct CallView: View {
NavigationStack {
LoginView(reason: LoginReason.loginRequiredForFeature) { _ in
self.showUserCreationView = false
self._payTournamentAndExecute {
self._summon(byMessage: self.summonParamByMessage,
reSummon: self.summonParamByMessage)
}
summonType: self.summonType)
}
}
})
}
private func _footerStyleView() -> some View {
HStack {
let callWord : String = (summonType.isRecall() ? "Reconvoquer" : mainWord)
if self.teams.count == 1 {
if simpleMode {
Text("\(callWord) cette paire par")
} else {
if let previousCallDate = teams.first?.callDate, Calendar.current.compare(previousCallDate, to: callDate, toGranularity: .minute) != .orderedSame {
Text("Reconvoquer \(self.callDate.localizedDate()) par")
} else {
Text("\(callWord) cette paire par")
}
}
} else {
Text("\(callWord) ces \(self.teams.count) paires par")
}
self._summonMenu(byMessage: true)
Text("ou")
self._summonMenu(byMessage: false)
}
.font(.subheadline)
.buttonStyle(.borderless)
}
private func _menuStyleView() -> some View {
Menu {
self._summonMenu(byMessage: true)
self._summonMenu(byMessage: false)
} label: {
VStack(alignment: .leading) {
let callWord : String = summonType.mainWord()
if self.teams.count == 1 {
Text("\(callWord) cette paire")
} else {
Text("\(callWord) ces \(self.teams.count) paires")
}
if let caption = summonType.caption() {
Text(caption).foregroundStyle(.secondary).font(.caption)
}
}
}
}
@ViewBuilder
private func _summonMenu(byMessage: Bool) -> some View {
if self.reSummon {
if displayContext == .menu {
Button {
switch summonType {
case .contact:
self._summon(byMessage: byMessage, summonType: .contact, forcedEmptyMessage: true)
case .summon:
self._summon(byMessage: byMessage, summonType: .summon)
case .summonWalkoutFollowUp:
self._summon(byMessage: byMessage, summonType: .summonWalkoutFollowUp)
case .summonErrorFollowUp:
self._summon(byMessage: byMessage, summonType: .summonErrorFollowUp)
case .contactWithoutSignature:
self._summon(byMessage: byMessage, summonType: .contactWithoutSignature, forcedEmptyMessage: true)
}
} label: {
Text(byMessage ? "sms" : "mail")
.underline()
}
} else if summonType.isRecall() {
Menu {
Button("Convoquer") {
self._summon(byMessage: byMessage, reSummon: false)
Button(mainWord) {
self._summon(byMessage: byMessage, summonType: simpleMode ? .contact : .summon)
}
Button("Re-convoquer suite à des forfaits") {
self._summon(byMessage: byMessage, summonType: .summonWalkoutFollowUp)
}
Button("Re-convoquer") {
self._summon(byMessage: byMessage, reSummon: true)
Button("Re-convoquer suite à une erreur") {
self._summon(byMessage: byMessage, summonType: .summonErrorFollowUp)
}
Divider()
Button("Contacter") {
self._summon(byMessage: byMessage, summonType: .contact, forcedEmptyMessage: true)
}
Button("Contacter sans texte par défaut") {
self._summon(byMessage: byMessage, summonType: .contactWithoutSignature, forcedEmptyMessage: true)
}
} label: {
@ -207,21 +377,19 @@ struct CallView: View {
}
} else {
FooterButtonView(byMessage ? "sms" : "mail") {
self._summon(byMessage: byMessage, reSummon: false)
self._summon(byMessage: byMessage, summonType: summonType)
}
}
}
private func _summon(byMessage: Bool, reSummon: Bool) {
private func _summon(byMessage: Bool, summonType: SummonType, forcedEmptyMessage: Bool = false) {
self.summonParamByMessage = byMessage
self.summonParamReSummon = reSummon
self.summonParamReSummon = summonType
self._verifyUser {
self._payTournamentAndExecute {
if byMessage {
self._contactByMessage(reSummon: reSummon)
self._contactByMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage)
} else {
self._contactByMail(reSummon: reSummon)
}
self._contactByMail(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage)
}
}
}
@ -234,47 +402,50 @@ struct CallView: View {
}
}
fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
do {
try self.tournament.payIfNecessary()
handler()
} catch {
self.showSubscriptionView = true
}
}
// fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
// do {
// try self.tournament.payIfNecessary()
// handler()
// } catch {
// self.showSubscriptionView = true
// }
// }
fileprivate func _contactByMessage(reSummon: Bool) {
fileprivate func _contactByMessage(summonType: SummonType, forcedEmptyMessage: Bool) {
self.contactType = .message(date: callDate,
recipients: teams.flatMap { $0.getPhoneNumbers() },
body: finalMessage(reSummon: reSummon),
body: finalMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage),
tournamentBuild: nil)
}
fileprivate func _contactByMail(reSummon: Bool) {
fileprivate func _contactByMail(summonType: SummonType, forcedEmptyMessage: Bool) {
self.contactType = .mail(date: callDate,
recipients: tournament.umpireMail(),
bccRecipients: teams.flatMap { $0.getMail() },
body: finalMessage(reSummon: reSummon),
subject: tournament.tournamentTitle(),
body: finalMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage),
subject: tournament.mailSubject(),
tournamentBuild: nil)
}
private var _networkErrorMessage: String {
var errors: [String] = []
if networkMonitor.connected == false {
errors.append("L'appareil n'est pas connecté à internet.")
ContactManagerError.getNetworkErrorMessage(sentError: sentError, networkMonitorConnected: networkMonitor.connected)
}
if sentError == .mailNotSent {
errors.append("Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer.")
}
if (sentError == .messageFailed || sentError == .messageNotSent) {
errors.append("Le SMS n'a pas été envoyé")
}
if sentError == .mailFailed {
errors.append("Le mail n'a pas été envoyé")
struct TeamCallView: View {
@Environment(Tournament.self) var tournament: Tournament
let team: TeamRegistration
var action: (() -> Void)?
var body: some View {
NavigationLink {
CallMenuOptionsView(team: team, action: action)
.environment(tournament)
} label: {
TeamRowView(team: team, displayCallDate: true)
}
return errors.joined(separator: "\n")
.buttonStyle(.plain)
.listRowView(isActive: team.confirmed(), color: .green, hideColorVariation: true)
}
}

@ -7,13 +7,13 @@
import SwiftUI
import LeStorage
import PadelClubData
struct MenuWarningView: View {
let tournament: Tournament
let teams: [TeamRegistration]
var date: Date?
var message: String?
var umpireMail: String?
var subject: String?
@Binding var contactType: ContactType?
@ -24,10 +24,7 @@ struct MenuWarningView: View {
@State var savedContactType: ContactType? = nil
private func _getUmpireMail() -> [String]? {
if let umpireMail {
return [umpireMail]
}
return nil
return tournament.umpireMail()
}
var body: some View {
@ -46,9 +43,11 @@ struct MenuWarningView: View {
}
}
} label: {
Text("Prévenir")
.underline()
Label("Prévenir", systemImage: "phone")
.labelStyle(.iconOnly)
}
.menuStyle(.button)
.buttonStyle(.borderedProminent)
.sheet(isPresented: self.$showSubscriptionView, content: {
NavigationStack {
SubscriptionView(isPresented: self.$showSubscriptionView, showLackOfPlanMessage: true)
@ -72,6 +71,13 @@ struct MenuWarningView: View {
Label("Appeler", systemImage: "phone")
Text(number)
}
if let contactPhoneNumber = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "tel:\(contactPhoneNumber)") {
Link(destination: url) {
Label("Appeler", systemImage: "phone")
Text(contactPhoneNumber)
}
}
} else {
Menu {
ForEach(players) { player in
@ -81,6 +87,12 @@ struct MenuWarningView: View {
Text(number)
}
}
if let number = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "tel:\(number)") {
Link(destination: url) {
Label(player.playerLabel(.short), systemImage: "phone")
Text(number)
}
}
}
} label: {
Text("Appeler un joueur")
@ -96,12 +108,13 @@ struct MenuWarningView: View {
}
fileprivate func _contactByMessage(players: [PlayerRegistration], privateMode: Bool) {
self.savedContactType = .message(date: date, recipients: players.compactMap({ $0.phoneNumber }), body: message, tournamentBuild: nil)
self.savedContactType = .message(date: date, recipients: players.flatMap({ [$0.phoneNumber, $0.contactPhoneNumber].compacted() }), body: message, tournamentBuild: nil)
self._tryToContact()
}
fileprivate func _contactByMail(players: [PlayerRegistration], privateMode: Bool) {
self.savedContactType = .mail(date: date, recipients: privateMode ? _getUmpireMail() : players.compactMap({ $0.email }), bccRecipients: privateMode ? players.compactMap({ $0.email }) : nil, body: message, subject: subject, tournamentBuild: nil)
let mails = players.flatMap({ [$0.email, $0.contactEmail].compacted() })
self.savedContactType = .mail(date: date, recipients: privateMode ? _getUmpireMail() : mails, bccRecipients: privateMode ? mails : nil, body: message, subject: subject, tournamentBuild: nil)
self._tryToContact()
}
@ -124,7 +137,7 @@ struct MenuWarningView: View {
@ViewBuilder
func _teamActionView(_ team: TeamRegistration) -> some View {
Menu("Toute l'équipe") {
Menu(team.teamNameLabel()) {
let players = team.players()
_actionView(players: players)
}
@ -136,11 +149,9 @@ struct MenuWarningView: View {
fileprivate func _tryToContact() {
self._verifyUser {
self._payTournamentAndExecute {
self.contactType = self.savedContactType
}
}
}
fileprivate func _verifyUser(_ handler: () -> ()) {
if StoreCenter.main.userId != nil {
@ -150,14 +161,16 @@ struct MenuWarningView: View {
}
}
fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
do {
try tournament.payIfNecessary()
handler()
} catch {
self.showSubscriptionView = true
}
}
// fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
// Task {
// do {
// try await tournament.payIfNecessary()
// handler()
// } catch {
// self.showSubscriptionView = true
// }
// }
// }
}

@ -6,6 +6,7 @@
//
import SwiftUI
import PadelClubData
struct PlayersWithoutContactView: View {
@Environment(Tournament.self) var tournament: Tournament
@ -13,7 +14,7 @@ struct PlayersWithoutContactView: View {
var body: some View {
Section {
let withoutEmails = players.filter({ $0.email?.isEmpty == true })
let withoutEmails = players.filter({ $0.hasMail() == false })
DisclosureGroup {
ForEach(withoutEmails) { player in
NavigationLink {
@ -31,7 +32,7 @@ struct PlayersWithoutContactView: View {
}
}
let withoutPhones = players.filter({ $0.phoneNumber?.isEmpty == true })
let withoutPhones = players.filter({ $0.hasMobilePhone() == false })
DisclosureGroup {
ForEach(withoutPhones) { player in
NavigationLink {
@ -45,7 +46,7 @@ struct PlayersWithoutContactView: View {
LabeledContent {
Text(withoutPhones.count.formatted())
} label: {
Text("Joueurs sans téléphone")
Text(Locale.current.region?.identifier == "FR" ? "Joueurs sans téléphone portable français" : "Joueurs sans téléphone")
}
}
} header: {
@ -53,3 +54,4 @@ struct PlayersWithoutContactView: View {
}
}
}

@ -6,16 +6,53 @@
//
import SwiftUI
import LeStorage
import PadelClubData
struct GroupStageCallingView: View {
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var dataStore: DataStore
@State private var displayByTeam: Bool = false
var body: some View {
let groupStages = tournament.groupStages()
List {
if tournament.isPrivate {
Section {
RowButtonView("Rendre visible sur Padel Club") {
tournament.isPrivate = false
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
} footer: {
Text("Si vous convoquez un tournoi privée, les joueurs n'auront pas le lien pour suivre le tournoi.")
.foregroundStyle(.logoRed)
}
}
let uncalled = groupStages.flatMap({ $0.unsortedTeams() }).filter({ $0.callDate == nil })
if uncalled.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalled)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalled.count.pluralSuffix) non contactée\(uncalled.count.pluralSuffix)", value: uncalled.count.formatted())
}
}
PlayersWithoutContactView(players: groupStages.flatMap({ $0.unsortedTeams() }).flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
if let startDate = tournament.groupStageStartDate() {
Section {
CallView(teams: tournament.groupStageTeams(), callDate: startDate, matchFormat: tournament.groupStageMatchFormat, roundLabel: "poule")
} header: {
Text("Convoquer toutes les équipes de poules à la même heure")
}
}
_sameTimeGroupStageView(groupStages: groupStages)
ForEach(groupStages) { groupStage in
@ -75,7 +112,7 @@ struct GroupStageCallingView: View {
ForEach(teams) { team in
if let startDate = groupStage.initialStartDate(forTeam: team) {
Section {
CallView.TeamView(team: team)
TeamCallView(team: team)
} header: {
Text(startDate.localizedDate())
} footer: {

@ -6,18 +6,48 @@
//
import SwiftUI
import LeStorage
import PadelClubData
struct SeedsCallingView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(Tournament.self) var tournament: Tournament
@State private var displayByMatch: Bool = true
var body: some View {
List {
if tournament.isPrivate {
Section {
RowButtonView("Rendre visible sur Padel Club") {
tournament.isPrivate = false
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
} footer: {
Text("Si vous convoquez un tournoi privée, les joueurs n'auront pas le lien pour suivre le tournoi.")
.foregroundStyle(.logoRed)
}
}
let tournamentRounds = tournament.rounds()
let uncalledSeeds = tournament.seededTeams().filter({ $0.callDate == nil })
if uncalledSeeds.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalledSeeds)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalledSeeds.count.pluralSuffix) non contactée\(uncalledSeeds.count.pluralSuffix)", value: uncalledSeeds.count.formatted())
}
}
PlayersWithoutContactView(players: tournament.seededTeams().flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
ForEach(tournamentRounds) { round in
let seeds = round.seeds()
let seeds = round.teamsOrSeeds()
let callSeeds = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0) == false })
if seeds.isEmpty == false {
Section {
@ -55,6 +85,16 @@ struct SeedsCallingView: View {
}
}
let uncalledTeams = round.teams().filter({ $0.callDate == nil })
if uncalledTeams.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalledTeams.count.pluralSuffix) non contactée\(uncalledTeams.count.pluralSuffix)", value: uncalledTeams.count.formatted())
}
}
if displayByMatch == false {
ForEach(keys, id: \.self) { time in
@ -80,7 +120,7 @@ struct SeedsCallingView: View {
let teams = round.seeds(inMatchIndex: match.index)
Section {
ForEach(teams) { team in
CallView.TeamView(team: team)
TeamCallView(team: team)
}
} header: {
HStack {

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save