From ff9f0d6cc628d03a8a3e281ff443e436417d8018 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sun, 14 Sep 2025 23:07:32 -0600 Subject: [PATCH] 1.0.0 for iOS is ready to ship --- README.md | 11 +- ios/OpenClimb.xcodeproj/project.pbxproj | 4 +- .../UserInterfaceState.xcuserstate | Bin 39751 -> 47577 bytes .../AppIcon.appiconset/Contents.json | 4 +- .../AppIcon.appiconset/app_icon_1024.png | Bin 22356 -> 21992 bytes .../AppIcon.appiconset/app_icon_1024_dark.png | Bin 0 -> 37009 bytes .../app_icon_1024_tinted.png | Bin 0 -> 12443 bytes .../app_icon_dark_template.svg | 18 + .../app_icon_light_template.svg | 18 + .../app_icon_tinted_template.svg | 20 + .../MountainsIcon.imageset/Contents.json | 59 +- .../mountains_icon_256.png | Bin 5418 -> 4100 bytes .../mountains_icon_256_dark.png | Bin 0 -> 3761 bytes ios/OpenClimb/ContentView.swift | 6 - ios/OpenClimb/Models/DataModels.swift | 6 - ios/OpenClimb/OpenClimbApp.swift | 6 - ios/OpenClimb/Utils/AppIconHelper.swift | 116 +++ ios/OpenClimb/Utils/IconTestView.swift | 579 ++++++++++++ ios/OpenClimb/Utils/ImageManager.swift | 854 ++++++++++++++++++ ios/OpenClimb/Utils/ZipUtils.swift | 24 +- .../ViewModels/ClimbingDataManager.swift | 152 +++- .../Views/AddEdit/AddAttemptView.swift | 343 ++++++- .../Views/AddEdit/AddEditGymView.swift | 6 - .../Views/AddEdit/AddEditProblemView.swift | 21 +- .../Views/AddEdit/AddEditSessionView.swift | 6 - ios/OpenClimb/Views/AnalyticsView.swift | 9 - .../Views/Detail/GymDetailView.swift | 6 - .../Views/Detail/ProblemDetailView.swift | 163 +++- .../Views/Detail/SessionDetailView.swift | 145 ++- ios/OpenClimb/Views/GymsView.swift | 43 +- ios/OpenClimb/Views/ProblemsView.swift | 148 ++- ios/OpenClimb/Views/SessionsView.swift | 32 +- ios/OpenClimb/Views/SettingsView.swift | 98 +- 33 files changed, 2646 insertions(+), 251 deletions(-) create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_tinted.png create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg create mode 100644 ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png create mode 100644 ios/OpenClimb/Utils/AppIconHelper.swift create mode 100644 ios/OpenClimb/Utils/IconTestView.swift create mode 100644 ios/OpenClimb/Utils/ImageManager.swift diff --git a/README.md b/README.md index 71be771..f52bac5 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,22 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS. +## Versions + +Android:1.4.2 +iOS: 1.0.0 + ## Download -You have two options: +For Android do one of the following: 1. Download the latest APK from the Releases page 2. [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D) +For iOS: + +**Stay tuned for an upcoming Testflight or App Store release!** + ## Requirements - Android 12+ or iOS 17+ diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index bbc788c..2543c0a 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -285,7 +285,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -323,7 +323,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d9d3b262109998ac0672b5d331ee7247e77f8ace..dac7ec28142cb329ceb48e874fe90f0ae0eb0429 100644 GIT binary patch literal 47577 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 AssI20 delta 20306 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 diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json index 866ce16..428b184 100644 --- a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -13,7 +13,7 @@ "value": "dark" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_dark.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" @@ -25,7 +25,7 @@ "value": "tinted" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_tinted.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 1b6fdd3bb580107f3b4f0bf4aa56fd2c7603cfb4..f6854e3c73191f434eaa49b8b7ac549db9991a28 100644 GIT binary patch literal 21992 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 Lxi#Z2htvND8Bbfd literal 22356 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 ZJ3LuV+VJ;fDR>`X$JX6j(sXT4{SOp&kyHQx diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..28241cefb1674f870a535014276f061157814421 GIT binary patch literal 37009 zcmeFac|4Ts{|9`d@=eKcQYy<7r)ZUwotaKbn+FU?g-zZ~sZJmXUU+C#UN1N@J9j#Vd<!FY4Sa#+I~m*l_Sd#A6Q%y)e~WuDFd-Ks!5JwJ_!p z?jiFBv^jP9))y&Fmq0J4J_i+S)cSN9ipJ{hAlz+Y_KYdu(Ca5ZrKGhjd`b~lGAlee zZ(>)Hnq}#f*COZX@7ZsU^H;TQRKVe{HY&(_>gG&;j5a!y@7{O}SKw}5Qc3ojmsDcQ zbv+C%m`XR-U7JoX$fM-Z3&efP$7J$wj(WofQzYM7Nqn8fvAfM4m_nndz<~v; zMp|xR_(CdR90W<9Yo5s%w5yfh#(LX@-OkznW)$PinUOGUxU0>^dI> zw!}8F@@MT>&L(*^AFPQqHp)c5{ndL+1wI{T4J>5qK0 zXWQPWcaaU*X|?R(nZP6LJ%iXS%wr|Xt79fcmRBFzo91ZMoWCdTDz>-q6F#X{FdicS z4QQb5ij@fiY`&R(8ZCB^G4nlEmr)xaFUy#@5$iuuD~@Z&HR5sYl9P<4{&HQks>f+~ zL1;kgfSOplw)?e`w#V1(zeKNhTWB<7w+v$Mth@Fp!nEwkpI_CJ+)TQk2hN>fDEdFp z+-dw-0wNwzQ@L37IZ(z!YsjYaRyZ&gc!scVIPD_S ztmJlLv{%dR#DgnyZEH5)*mWd&mEsZ+$T(<2+@=k3S!7>&UN%{)i*Dr4{y2_a?^cVY z%rvSoJxY`wv90Ho9wn|?4igPY2X&|Euv)s)g5UM54)^Q&&q(jMjE=`l-ByaKKt(uMemuM)AOeqnEW_>~tdPB*7Dzl}g$>0z1ajh|YdBOO* zz$vZD@f~CpFsYUVM2hg+S}1`HnzI{Kav+?Ma_jFOXE35;Lr>Sgd3o8)bjTH%5F@+8 z*RA8pk>R0t&;b4nxf@4HKpU^i^pC2$lzWTgcD%EBFSA09<@YsK zX<_S>;FA-Ot(L z;^-h$X{br!4Zjl*QMPWjJ!?D*uhw;4Qr7GHbxAp0YO)b?$dnw^G##x&X`YU*8$=EQ z`j)Ig?qftw%d@813G-uQKU~S1gv$oCk#gJ6CiC|`g+4bFE;SHb$x9DV?05k7h=P{CsD#?Fqh~Lnk@=iSLi|-IWXekb4wt zrRvi>5ct&N!0@exc*CVP4+=p3l`9AnA5i%d!s1x^RAV9It&;w$#~BwtMQ-Jt)2p#+^%{qb*-t#u>9;T=dVWP2^s06umna zWyGpdVE5hU&EoowH84z=p`sUjO5%N$+I?_wAYdt5*@7S7h+A^S|4D}_W5n2h0mEb2sh zvbA|>YF!e^ek&BFLL)0+#t*DhgQEG{?~youhX2Bh4(6EXtD7 z&&wf+_;%V@A&yoc-k4?8>f_{4Lh+k&(VbU|JCsKTBhOZIpjxu9RkFC38Y6 z)AQo!=enfM8b!!h8kmHL^={)vW6X~Q=-a2?5x%o-(s`6_9Cscd9AHZ==zsQOf2LLY z5%k)mXk`USrtdAL*FX|)Qm{=6H&d{I>cb_OhT6k=Qf!1n&`gRWVOwk3r`a^p9~uJ0 zf4+6pfX^S3RZ(Q`X*HLSx#@$1!hvc@fFRBd?B@Pdb{jP|Pq)ix7wi5Vc28FzY@oM! z#v1~ehe#V4JuR{4==UAuFY2I&=g(B@ziIFA^lcsGfupE3`J8bM>FWNHmZuH@sWKZT zS-s;}7OOYO^u$XE=y2RtxM&SiDCIx>V1^23ypBTwy1x#DAk-qDJ> zQAmO?X+vDgQl>+B*DpoWJMQb;a;-Mz&H&?hzr7~1)4C&|4}Ym6KrSn|Rn@umU0o7~SsPRCMICb9kh8Vx%Xs^IffiZP?K4Vx zTDY9Cf9Pb-O+iR!sH1yKXRM=JZtWNrKGM3&oI6Rc+iTdjSZXt&5jh@x4Ua8M6Q4HC zi$EcVhci>Evvj5029vy%DuT; z8x3-H$>qjvzbnTXtG>tX#9BLXbpA|0IBVitAZ-VsdWyEgNG8boDO7YJhn|xcn@7(H zJeasc)9&lmq*~5xm_keT1XT>nb}f0*r%`n+5)f4bi0U}ydjS5F^rM)JXR(C3GBoFg znRrLSuGX~5+40U4bpc}bo9=wrTqs%3s$C@5%&INs7wJuK+up0S6$aw89Xa+Nb&kp1 zYE$jW+LR5j_0$;ZM0;EJtd@^mw|??_h#1cAoxH~e&O)7|uZFWzDY^dQ3Qk&=TaUDw zByQS=6~O4}F_@8L>@>}RhX9Q7$5~niQ}<|;Am;W^Zv610R13M(L4pC>VL^Y#nRMII z|JLx33bo%GrPhRHJknfK0BGoGYfy4zerQnAyQ_7vO}RTH zop=86f3>1M&#uU19+)~JurV>_G`*xnq2bW+T<>pH_ni%+;gAztic6YV7qVgx7tV(>!bO?MsI%Nd2)l;_m$H0*34>9%krG1h(INK&oq^CwW)YMQRc zxG7E7t{cS}sqWs)vd*F$b~A+(uVzy`=$6@(HR-OOK5Si)_}q{qb&$j}N3wD&;ko*} z2vLrj5>BR0Wm|>JbuGtgf4>9v&K=hC(aa*X!o?BPwz`}lSGbB08NeDp<-XLt??zXj`R0-5;8V>Z0NY11}XlD$g*FzEKkv4 zC?97PGMPt3z`0+q-104!&{d6|?2kRnXUxH7|9Cmf1)wgMemcQbkC>-Z_0Y|a%b}%x z7HNLN4_ykZc^g+e*X-YyZN+M+r1)rfY}g|LE$y1kb0!bZ=E)VR&)%^itE=+hNyN>T zt}gWYAZg@fL%#pUZz;x_%g5hXRfAV!Ox!|sOcVz`9)CO^^LYG7bi#}x^w(79YBAl( z&eh`m__=(sSlV}l6-OW|c-J)1M|XK3$y_jbtVV__mqr(k@Lk98inpg}u& zVzd7?onr!|0FU->9>*GYim#|XIC~5Qb zeX1-jZdRFPkt0X5ui8F=T>p((H(yeraOjZVyY6Bc)EFNTB(IdkN@c6AAvBP{b$R)- z`5et+M~;?X^#Zd+a0BEmXpQ-gAB_$Wx4THN>6!(fxKF9o5Uph5S#p!?c&al zaJTsxLh8q{KAp$J{00Shz=aAnw}w{D+H33uzv|)8INCKFS~7j$0j!(>+&N-M{N~0i zZbbVri^Z=+_V2B}Y#5#3sv*!R!}U;7f(hJ#Co`BWj_gO-eM^WpF5$DN%?j&Hh2A^}W$? zXR;IBsnz9J^p=AElpyy8Pw+YPIZD@MA%Bn$SJ!ul>FvW01T!fnW>ZUv!{lpBTq@hJ zB+IR}%txjK+9uDBBkJi#Qz2K_%3y2J)@HRY6U+?wd#JJCMXj$cVZRo}>3BqTn8#>hu=5|^ zqH|^y8e6!2C8W598}L%g{M|Zw3nAED0}h)q>}gf|=<@IYEu785J{d*zn~kl2DEaeI zYJ};&z^v9*XO}#k-v@CR!uJv*+^3&MDfG?>aQtA%)k`*a?&%#=u6oxyv=*Wj>Mr{j zvJ9b!^(rimKktF1r@eAg`g8 z#8muHOW;-zr&70Kjd+TkXiY6PVolsfGi*zIACc|neIF$nUYiS|Eg=Huznd<8$I!yP zC9c*afU6uxO$CK)zw_waCJ85jPGM2>rMdkFO8P>bSp$8c2|QzkNw1uZ&14Q(o4R8K zxToOeNKLOVKb6QIW2Mu+;67ZNA`UyqW+O+V`=ZyhFNbhDa^aB0B$2y`jpIR1gxpJp z&|H-PlAJln=vYI>m5y89WjrK;XXlm;n}(lvz#2w#fdsKK|8B3|^=>s30|&CPgneB-$_k+x-pBdIVWL5n zIrt2XpGg#lLs!w1W6=qa5pep}-e*SBCV&#e53Q!vyf43VD!ugX)$Nb$qZs;$_i}O1g z*?1!ff@6A@w7UW_F^7(s+Ic}=} zy}@V0aZK>Kx9)Y{P8_Rk=S~C-93d|xW1017tZi{q@()o-jKdVXbH-{&)@$;*BS(u!JJUgrNQ}@b6{m4`grPfRPy}ks`4xR ztP39Zls7*7i`V?!@M>u2PB;pbYteNUqmNz%U zRLy2tp#?g>8)-9c2cW;Ug8Vb*x|xzqtM)B`Cf@GHO3l38U%=#P1LGziUv_?Dq2PJg z%Z|1b?+#fHdn-QEYq`wAOg6p2*UlQW%5SKIKR#o$*_P$d@Uk?id}miw+j{OAigE>S zV>_l5AWlke-CJB;#|dBg7H5aNl+mohyc67U#udEe8&;H2UxXFC%gN#CvwVFSONiJ8 zR>o}jTfVE%nPN*HY-afw4NeLcMaPn-r*Zy;N-kVa3X+J8>A;Ss10M(ECeyB2l?0-K z=e`D_;`0yk*3*ue?^Hqg_1zHf#XX{&@wcZDj6$QKiOdvq>q2HqlFJJ2NQv;67MJmK za2Uss+~&VHBwq}E4Kg+w%|=a{^Gxd97g8tG;&xG;xI6+W<Ci&p*nGYA$EB_r;oR@MToa91>Oq$ zj(87=g}i-+iGdilb-~d=ZzRYveYl2gnQa6$vBpNnJ6L0N;k;q<-`bgT&XQ0eyN4Fb zVBJqrlH$%k6`x0+Y~KY$8Dy8K288H^3@-5-vAKCDLeo+rKf}aJJr_E1Z>|V;MC-Q3 zqE7}`LUY+vb{31$kXU$*_iJ`>dn|fHu!xfsk6hTpo0+l-M<{$XOvEuhK2}z_cxWZT zan7a~bqxQQd#mw=nCOX-f9)0YN($Uu+I%NswR(W0)Q!GqJrTZ;!5u|N;o*etrO>Jtug8NOnp?+N8(p)!rw!c z-1~{&_PSTr>^zXIr+t=viTRA`!lK_;3vnwq$}inccE2e5u^Nccuk@#RvaZi8Qz)CISIE`qb~`ZpPr~HRs*i+j~}>IpbiFG>@4IpK-QkY z$}T7+*>L?4h&&1xhd_mhGh!}aoLNj`efRx&#CiI>ou#lX`&q$L@Y&SWG-Q(;nqtUb z#cR7!wk|N|4qS)5qiYE<+J0Qyx4${bhNqfb`f)TG2Tpt-m0*_*Vn30;ipM$>2#UZnp@nD2Py)zJp7t=*Wb>)q^ZD$7SbN235w0g z<*JyqvTcuRWf5gTgeZfeOis$6i~uG$m%4PbKYMs7akMtes8x5T#}4lIovyZYW||~~ zR3z!WZb>#gws0{Cz)&K1EMK#fj zI)qKixwJwddh>7zI*0zTPMP~1QT_z$(~$Woa)zeIO5UChKOon!<5@~nqo1eCYw>(L zdga*9UumK9rZctBQJUP03gr6NC!~0d!|(Zdx^cg&{r9zEkNONy#Ew~ekJ6L>l~ZKJV3~@r;my|d04h~y{R(?^UW+%K*+|3 zud6-tHg)Q=I~y#o=*LN-7%%A?d5PNXdN!8;%4-t3T%ykR9<%m-vf6FGZ}F@Bp*S|p zmaR+2|Fl~HDUYkYgKKoW+zCc(UBFaVE*k{2eBIk$4rPkaXkZ^V4{$ak^40FE1XVm- zRkYa(ozm|=I!qof8W#o)VY~q~uMe(~TA+4CEfg^6fPsaH9AGWIG&KLVSa_yu8Qz8M z4;C8d*k+8tS@`svl$>j5v+zs{F&qy}BJA1Nh+4Ma*^<}Z%trI$qXpF;{Ry+)fcQyH zwudPA4LLwvKNWHs8f<$&hszAiuH2VTJ&aM#6*Ou;pu9kzOFqWH%-g29D{y&nvQctm zEiN_+Z#6p)PYGHb!9^6@?QcW9{ct&@#E1#LFS^HD>UAgUeR!gF5SrQ#4nse6(IF}x ze35_X0YCxP||}b`SM@s@*O!_k#I% zHMlV!v`xX+U#_zQxn}kD@rEbz!FGsh+;s}w&+I*jFNrrekkx2(Os22Xsv5UZ2}s%; zSJK1~`NN7AoWVMJ3qYg?+|xou`uLAd2poL#)auvoIj1P@^g`0Y3c%8iLwJpFNNy&j zZR)akaUT_nRo>?jc_6&EY6_5klPez!m`aa*$;m-;1`F2J;1J;kHuzd7I%vM{&8lr( zqBFmI?{ngpGrX^9K(m|A90oUugmN}L6E`W9Z1n2$*QjalJRl<#t2xpzaB#&K%FjC{ z}#PS1fxUCz-wjlB2kpyX6)9GfV~BIeME*6lrR6y7rgb???p~8c5|J zL9f$HwQB^C%X6-T=v=D%_%PA@LCx2>c+UcG78TsH5RTT#bvC1(0qel5J;+^W^{zV! zFGAnFxm+>3a5<@XCb}_=RzwAy9ps9z*t)D2)(6Oc`mZjmO9RzUk?RO4Iu16kg2Lru z@UvO1o@0L9)sVLumC^ED=%|-a{x1WE=7RbhG;hf;z>v4D7rVc;{Mi91QHO;g;@>2g z2bj=E>^{5FLQ$ZmF&)ysDBLTcP-5E{+YWfZmGv6=h4yoW2@XyrUALMK%m({)v+W6A zSh5>k&ZZ`xvFxp)HRb>^!$LS@TDNtbDEB-*#G(jEB;QHBa!JOvnC>wK(Ea=wXYqGl z%`>?TGVN?%qP6KS`uVw`sd@(eUOSkMG>)*tRd3m=S5NBp$y<+o`)JV;qB~b@M~G!= zW74I_=?f)CU2;(m=zw{6BN%=}`Kv<-%Cu4PYns0-$#~~G!bERKE?S)iR_7`xecO+H z%?@WBe8>9*L=1Y9?c{CnB{Ml5cV3o*)!${%@3#YiBGC}Rso6>0?$C!zy>ltY-qD6$ za)6C%uC`~(lA7)gzB+5}eQ`*lw3sOf)Fo$Y*-yUi@h@X-uVwlYPxA5f>yTFwe$JI0 z%LiFH80>cJpOxKD0#~$1T@>}v_K57d*Pb>4*A6+Fk665Crr}mWw6EMD?b?~c{q^l^ zon~OaP?)D2{U4Gsu1W(Z<--Ga)DY{D8nB_}Mjs&*e8W|ax$D$dJr#<&R(l1F$-w|>YpAM0GGcE-!1jZ8lj(01+ zO)8ck(>msAqg!$MC7-;Mt&YHjkEzn-@S#fo*S$S5ZE)SGd@_oZcWdX%rkd)gVVyGO z5{TG=@TO?fV-Ds@>2!fy2gIs0y&h8@QvjsNB9h*5iX2th=$;aMx^1^_Md7^ynnv?@ zwXPV+c*m3GcW)YQd4#&5fl{L($e73~+bQeeSpJ;hFI)8fHxSR?Yp)(*0Z8f~MueE{ zF_|&wk)kz4_lAUPVFR?y?+= z2kAv=4esdwsxlkjG&~yX7m9aalnt%kUhufXzRd+#KMh1NFcf};n!83ub@dc9^KdCm ztg!9t48EMX3>5x2yd+#bR5`bvo_0Js&UG4^H{<9(WxR8H+>mlo?SqgJkgf%1KEQD( zSohc5uXbwI+T;=Rp-QNyG2+WhkB9aNh9`2{2*6rhMF`N-E2*)Nn)MvJy>9Y&t$eh3 zqT%$d9Y&ADNZFs8&CusN-_&hA8cqjj(>Fw9Eif>`qq4*)E#*jboY%BKX|msu9ii9c zc3pCb*f%g)cloHfCp!~1Dkm&0iHxq!*wJc-9MJv>x$gLSmHG#Q-^l z+rNQ|x~)|hHy;^tA<48yP04ufi>zh#%Y|2UTPwVPX{f;?ByV>1uYa(HuKL{Y9wi?m z!0}7JC2VRP#?$=XdojJGy?}=2T4}`-Oy^zL+PA9KZp9;;=!X&vuMK46pYbP-ZGiPH z+9gtQ2W*2^ao69F?Nyg9b03ILI|JimTAe1NTeR%j`zb%r;XQ$9hX*h2gd3zewBOS_ zIM^rr4RL2vMEm~fg*}k(h{;w|7CLpxa41q-88_Io(@8(kuoi!YHR1!Usy~67jk3_) zY2B318s46eU|9U#wpzg#Sc)Po?yZT=8?I4UD@+qzbugMc8|z=CPF?i=I*|_usNoP0 zu{p{m+0=karrU$0g-mCfExs-1Z< z;*&8fq#hCZa;hw&RDAU<^Vn1S6g)PpGMmlM=m!jte!@8b^=EdByG-*0!|w}3_cE7P zy6NAddJa}j3qeF&0%!75$91#q#{aF`dJYx#1@@Q66z98o=LL9ssK|MmtvWK=wpzLC zae}^P8fX@v**1n-Dq#|)>ND=8>sl|g=hSXPpiM&I%3Q z)y$ci5z#O0f7A~QzYDVYFqOq{x^L*oY-U17tDBzHrc1K^P zRe39r!SDW<<+(@@#R03ipK}4rPfQzV1H_Bkh}&hQT9E3Y5B$kuCh?n!f{hAmA#4~=0V(H122VRP{`P#N3}m2lNn-Byusx=_5$_s?bV zZ&m+2cB>y~DHkNPs<_RAQ=DrKI*g#--t6K$|0eLl^g>!1Xhs#qZ}#nIo(67&3FrKK z7K2M7b%bS0TTv1FX!y!0bMMvpw%#mYOcAime7Uq9ZNq+;WGW#>TQN1vQL50cGcjmj zjNl;>1$;Ht_1%9zt8S4}4Ldr^$eaWN;}63eARQJhczeO6KPFJTdauqmZ;zq9rSf^$ zW~NXOWiGg0;grvGl+axO=%a{Njky>3LtW(%!ceeRne&YDaUD=;4>Eo=j_P_p8^_}^ z33H-^zI>VBUU9c8JMKIK9UQClxxRx@3;|oEyaGVDCZ8}+J?lu;iAza!uz2zat`EXYz9V3>sSkOGPsSMx-8V8u;7NRPxA}?~A;N4I*Z$x*3x|m^r>V>F`|( z?V+ZSBS58w(^r(*AbzWQOY{L-pi+#FB61g0Qd*hN_!cxL$&uTzin8{hsTOAVQ$rFG zE4l%eP2%9)71X->%IDvAzTpKn@Eo_ZGFtgyLvD+V@wHldSq>Ycl zqXBf$n+{EJL88q3j|OV%0SF!lB4RC=Z96@(FXe5L>4Q}?DUrsyX@>x@*Vm8zGRBOY zanZj&H}H{fnQE5NanOinOVi3@W4@n;!_pMdbGaYqfU6+E0oDtZvt`vy1iK-L_)2EL z2e-OtX5S;DJL^=_*b6~PNwvnWGA8c_gOjR|=aBH$dMan9FX$MAO{AhUNBQ=t>Xdj1 z^7rz2vAHzO&7jBXh;5AKdcBWit+fT9FndJ(EWOBAXnSIWpD*@%4_MiDZBRKw7lBw(N7_ph?Pv(_}$`%3#uMMv?3q2BjEB{lOxoQVE zDlufrKZkMG<|9+zir0<lTNI)`-@uDd$_wfvqtL(m6L(q{16o zT;nn8LczK!2mo~#IsQqtHKrN;k9Dw}HNWP^K)5!6%9xA@24+!l3&IAxEgxwWM-_xeP$D&Y6CGRtF}EZHsX;?s%FPHk>i;jS9jOHW|sP*WL_cBwGI=Jb%n z%$jPcjx$KyZ!N`^$a3_%*bQ3igOGq0qZGvhWR1DDiwa~8+=hW2^ad~sATFRH82DN_QuKm;+Q0MXNDG?E++m9)j*&we8lttpyh zTaA_y!<0O+qzWxFJQgjOTMi~AtwXfU%xhD%;2anB%_LJ}#k<0RioXVV8wO$=85g_P zgTS66m|_+g-m{SnEp=NJCCY^KxXwo6{nceEAl-5tF?9iTX|Enra(2b_#-s<2t_J<^ z#e>Jz)V?4Mzdv5Z`c>6a-ZQW%O&cLs$-zHXS_2jqwkUUKk z+)zt2>4#HwL}yK2_+b3%E^6u(M+WXD=&c(E%m0Zm$(fr40p|pd8R2*&f5iVhrnqjr zZ)%^VgZx^x&$1aVwnKX*k{ubg-OHy)2`}^09o>Ei#lj)gj!d{qek-E9q!V}!pUBh) zFF7Nw2VL_(5wx;$6EUt4eEjv?oYafifg~*cPCU7u4XEUp!vlL4E5V+FsxcXpOG{rY z@}aNq&6td>0-nNQ#6#@-Bbu)d^Y;`gUbN9nZZL}hooy1!rh%vX08z=SzH%bd1jGGS z>#-7_cat(3GH)@|Z%ymt3}hh>y8iKA@HTa1D)Vf z4?L57ylu865(t=C8S17Z$LVNZ8BhWoev%S$T8z}-^ler_@2sgeLr0mHhIT)A*;r1w zKKZ|!wwzQ&&A$h%!JIYW2r>TUJcBF9x-ofp6Ou+v5j9Z%s}Dp7_#?2;cH%y#MP^u2 zZq;q=M6U~#`0v-mxIy%?paZJdjCmoD?%~`(^j8Z{8$2tO{_etT7+&5q@j5#!xZKh2 z@1qyR)o8BP>5Y^pJEk2__y6Yd<>K6$}Dn(f}=E{4B zcsZ0QY+anEz_@+tGg6HO7MBwyY)k&+!nLxAhl*YNX(!ApYmU%Z0#NI9`dL ztk@X)SqTsdp5_LGX7G(J$@ebS$8=F*9;0hFy*lZIYEO8nU(Ds*6d(*o?5dE<(lY4=7(bJX zpv}v(#OZ*iXTmBW9fvuU{?SW2LIX{0&PYGh6g@cnYwitm*?T+~-JXNT>Akuu+5Hgi zid8&?WVDZ#@v-O=Flk_fq>s@qatHqG131t@bb;b}UT7gQAXBEMbAbbD;=g}=Mgtxp zSPAo8Y-@!I?QPgq@GoPRU5Gr^Ae_T8verAS#J;K51v?)DHlTs+rryf|Wn#9cm@JNgTPbPS>jt~zj%zov=Ay>|(#N;zR-?K+YF zK#MH~$C=Y~k;}L2TSXZ`71f!-(aswJKG=d8Yh)0D$G90^Fyn{wx)Z0kHNk}H6pNEO zTRb*^KFn#As5IamDI&mXJEJ_`_NZXB33og{5bQLL0HdT=!EoBBcB`Z1b@nx*l0f@N z*};5Gj}3||n}M7N>*?{~O+0X+P!Mue=S<5_#R-bspWm;iw+P=*fLv5sGXpq2+0VcN zkOX;=x-*WjibV zcjz!~yRk>>e$*Vt^Gcp77U&YhKHEc(*LKe6p~V4);;Lr9k9}hC7dQ%9V`a{HT_I?~ z6WL+hK0e$^KQgxNDjek9M7F~DE{N>d%tB+ z=O}W#2kjSYIFh>Ev_h0oPwnE2oY2g_j=J{Kp!?513Q;2NKeXNadEH(Mqym<|b!CpN zKcMpzht5o1^or4qFq-%LY9^X;!|{*5cRdDrAb$;_EiNtg=B{&eU`4>0c3G3`3POJKvgObRT2qGvErS)&}XLH|AGC zMJ`BgQKVyW>?$<>peoI=@WhkF046EfPU}RmL3GZUy_4_P3B_J491b4$z3LkdQ%Yh*YWcVJWhAPbz{TEaRNt_2ZmL^iFdL42b4()J`(6Oad0B>=x z-Lz6vs6&g#biT|5U@BBF`q)P=-&Wf#i1qMG@i*UH} zT)v}^<#0&dR(Nm?Qge+}!(Y!n4{G#^1_-?0uX~+w)ui!mV9qfGm&;l}gg15t&qq`l ziBxhD8t;YkmktACei?DEMauY@5n{Hr|JnpjRM;uh@edU^DSHvqxYUNHzMzhv`7@_~ zQ@Nx+?1m3ySpj>u0RiK02|CDSb5v}z-{urff-X~1eM!R#i1;lIM%Q0Bz9m(c=ySNh zbtqDqFJ!FFyZ!SjLK%E*t(FwuIJ^%UKj$(#+$%1w20I}1myGkbwY z1KhSTPT@4(P+(@sbdC$Z#)pwTZ3OZM-~|o9lwadpQj$VMHQwe+>Vkk!BDkmLI&}%O zk%BN@w{~&ZfHA7*yQWGWJhLe0A=I-!E5>#hc#ZSOALlPM5Zi;Ui(j4NgA~_(`AO&bZl&w!YFK`f;Svp-{ ze;n@dfuyctP_H{c(&-MY2x=S_XNV#Erl|2|MxrlAHy>E_%am2?cXr#4koS0qY+O1` z_Wd2-61KwzSuB8W@Prod{j4&1c{+jT=UF%-jzYe@;;-{e0jNS45!UkGvcz0{vZ{*X zQV0nDGW>^rlMoz!|1@baz>l8DV%ZLhgX{p}v=3+?nPRunp%tiB`q#P(#J?vNd|c1t zJ5#s-ceyTjUPlm8bVd#(RD&0oLN_ZGCT7>Rq35pZy9nJZ@>#;$maGc-%BLW_6nJh+ zsLTV=|&5s4C=F!!JGn$gV4bupQVuTIppx1 zwLEGg=(E@e&85b7kK&m>KTg_yIxi%u z_;-U6Z(q_`yt0JIMzNz#I%hZ;=DnoA zR@u>tOch!QHqQi+gi~O<;dLX371>uH%i%JR-FN!kO*SRU5&ffhH7Tnm;gnyb=xd8aZzk2FyP(1 z@F)cbXh6(ECyAdgLB`wj+OePke1jH5{D43v|Ko2!j90WEWKdr;@x5ozGC^GhkzOPE z&V}1G0>TJlmqqBO@RBKYg|2^QZ=Jaz3m)$z(d>pTvp`!$*dv5tTaKw#Th*S8(p-v} zOws3NqMI%0Vh z8qCiZ6LF_r4A*~n9DK~di96y~^c_CWX+q*em*dLMiB`f(0s* z`X^}HLHN1(5?>v5R>YsJ=4Lulpz#Wy&b&kUpo%rWZ5R7RwA8zI5 zIttGcyKF!g&43haJ&gb6=TeBsC~g88{}w7O2b0yPacEr}U-0BeC*@ZiZob0UFN3OL z0h*@#WPy+fDtPf24wiDv^3%K^7RX(}+YTP6(^iq@&Vadm z!eKe-Y5Fb5sG(<2cUiDQntl!q!Q4&5!-LnUYe0WXJh&0kP#u1ByVc6#K9DfVa1zyp z6A&he)O5c}1KBd<#HTJTs*qtQzIdkAYgtcj#c==9pNC%^;5&C=9rscLxcTT`$6Vs| zlL&-a;TnqAWSuaOzFYJF)VnE1a-FsO3aMoiaZvn8Ge-wvM@;3 z_gJ7+c;TT8)L_G-_}~aXCybe#Z>L`4xgSf3)OAp21!qbNKE@Pvn-|Xf z8WWy`Y=_;lKz6{@3qkz=OnQb5uka9N5saNsutc4f26hwW7jKv{~2%Ygeo&w@P@4NE zjlv<#bS0)NcmY*MvgApBRgZNrB}mtHu=>y!|7(?N0CYQ$DwV`_zB7oK9A3vvZhi~u zErP`2dL)gf04T~wN~VhEe^Y|SjM-57q`W=>^gBSS8WFD>hPszNqSK0g05VJ{);TYp z(G?hy=Ey!(MNKM(T;FY3nWmd^#ZZsaKMdOr5k$X8yyjJM1xa%r=Q5e;*9oBB4C!A% zJdf zhJB*B63owOT9V_WN`ONLDQa`p;U}4)AZ-7!>gpucdNJd0Wc%i}0emzfz!OmC zhbKMy^raRT#aL(IE&NEE051!%noH0BP?kc748)E8htk$|#{n6b$vPHw%P}o>t$6rB z(>mDO42ywn!e76Y(*QlgEb}jeYzpwfNoky1%6%{tQC9K%8XA!?F!%j_XoHghKwY6K z;>Ey2IKzH`(|?3Lx$xKkbbo=_cEWw5rvNsQLaxbgDH)=)`o(ebjPH)PY2P#}}yP1C4{-c?a+(k)EnV^1*h40p@ttV z1A5+Fq}vFwcK`_!sY24)n#+MlvLD7}O~4OaPCa9$$woKNf&2|55s8#*%mIHmy+sQ} zzgg@sAqTBOD^E{7`iEHOb$)K(Y$YN&JtMbY_`P+s1@F#U#%8JAqzX9iB(htI8s-}waUSV&v>OdPL& zYAGf}yWPXaZ?iRfshEp~&o!8$Ty6(-9OB*pZ4dksWaoGIsX}P}>pon+joCP?7Oho~ zoS`3RMlcqH5k!()+*Kut@qdGTZ8zJQ22wkibbd26$N+JsUV&r`1I81U4QMQezeLnx z_}BPymf>d(p{Zv-;l7EFd!jV#z!PPEtNMZsOcpDkQd%Q{;FdZg1sqtHXdqb98Q`aE zp!v)n2EaO-X8<;qBNPblY}WQQaBu?WuW|Kr2VszQTCbmGRY01`1lrbkg!98Gld&>1c03TzBrkSN>}a#UubzcvW0v|(@XjOR*fp4C?#29~$wtCY z(oo0GEAm>Yb1(c*rRdR=C|R8=AR>kyMDnxc+$Wld#WjoLYe-|ty)k(5VIWgdJHL0J z3O>Xu+#c*e?Cc1-8^aPAlOobRm* z=ZP1})~^#^K8abbXlwauQ&51d@;Z{JcGzD}H{A?O4LU^MzOU-<(v_Cl56@bEyE0n3 z>C|+_znJZNZiqU*{d0N2A2-zh*R(r;f^L&FeSe|vS~X5K6fOQPmbUX7YTTUSt5*`S z!sxVV?Pd>A%&oc-8#iOUs^{hA?yWJJ%ZLqQaW7McD{mbtu{*KGF3ACcW}{(BM{XLu z8k(+c?|+^ORm9v1$o9`2C4heuvo$Xr{d(U?8|ceR*Wt(X&nq3b)VVU2`mJsa)NGpF zlar{KW?(4*xp+)9#|vkxfgxu%f^r1pC1$7Qsh#QXRzg~e-|i4!r{^sXLr4F&zldINFEQWwcV&6Bji z&-G{WH}F_fFMgDKrul%6i!|U~L2>;Vgt~;(qa+-0D|aiXKZbp7kA5 zCp~&AleRRV@kKj@2lTfEBZpbW^Gz=M_HSDYyyhK%k>n zQgXRibKGY$7`UvU#?}6uWV$aSXrJYH{u*?O#naz^dAyhvi%%$)lWPyjfXza%pU zg3V;-Epc(1UD_8648rU7LCv+hFV@zMh8zcQqBlN8Dao4jz+$Iu3vR$9&)zn}`{&Z1 zz`!%p@~?U^6=h-$6|p?dj(^EKW(b3D!VuoQ`84mMos z_7dNG@p;EM3I`$25iBH7$(=O^hL*vse|^(KP4Wr&e>}`TR;W}+0GNAoD|k>l{<)Ff zWY#}1;JxdjP|XI=Q3!=yUY#g9oIm^!K@giJ`s`TD5rCTccq4pXX=yBsyElj6v$Zv$ zOWhGxyBoOf+V|xSS8{=1SEROBCdX(N-9H6&?mXnO%YkwMR%4`nFq${s*G+#}ZG?-3 zkGRJp6ps5T#V(_D+ocz%%+CxXTFN(r5OUnbz$G+ zX=#ZQDua`2+OuGxWO$x1Yoyv8knhTP5tIRVjd3^+$iXC>`w6HUWIGlHv)K25UOfBg zMuLP+a~X&{h!>HOTmD}(Y2MHyZQ9BHx3E63)0iLmcB2)3H&9g(_=9+So%eY9FiTkt z$mHxOi<&X?64oIkyT|7&y)PsIsMV|#$;FL#6!_9^-KyIJwDonywu|Kyc`HVZkfmBZ zpu6I=tQMiRZ!l1Xjq5Jf$LoT3u)H~5p6Zu1ehe?i^7m;E9Yz5&1KHk#OO`cpGFEl? zi|zZtIvHuHwCQI@(U0KWWc?6_NW}E)Ymqu3U3Af##E|)j|{Sm z1~Ne0f&lMMUElxH-nWNCxwVZy?cVae9e8KAB^A@AvWrNPLoxOnsjYZL2_YmTCZUlr zGpb#t6m|zBr<9}^l;aFW4iP!kIF2znGZ<&XjG6h?GiKDg_wW1r_s_o9b(PDrj`#V# z*RobZS>gJ%%IdE+HP7ZI01LWr^g%3kt;!LaL%x6=?g#F?F{-`#>HUtX{4EMi*$&Qb z?^l6ZA!;J6XTl=9e-=tx50eEKpw)_Ebaw!5jNF6dpZeHT3EQguOS1gp&X?N(U9-Qz ztnsF}KaEur4bLd=n?YD3(n7zCq16;S_bR!3x}}nyHv9l6_VT~jg|}{PG4)ozagBdM zs}9N60=)v+x&Razg-v~@RBgVAL_4^Oi_z0#0k{!*TLBYrqZ^B5>4$j?ep6i{5`}Y@ z0lQV_H9f8Ez-)>Ih^_unq=kNeyX9blEmha0@kYtx+^UyAcCzF)v+s>4U5#+cep$-` z>L${=*Gu)tRW8oBn--&(XbuzYI;@%Yl)AShB6xF=C7;D+DlLCaNr9Y=D-aR@_G@~Ej8BN*cBdtf%nTW|})YJSt z!4{Zm={ps}ZYo{eX;6|(p93>$B%ifz4#2fH5)0H`CySKE1Ci*pe&+(<*2|#z{&e9{ zpeZuwzgpc*xYTo*>}C;2pa~b{>#BCpoywWzB3C9b3@9^*Ev8marNkL zN;RX3?WFOBv>BeWUH-CTbpIUa*2q!+n-V>pR8`N+xu%~i_`|z5Oas0p3C!ca%t4G4 z6b!+v(f9ZO=cjt_Gn;&Wt$anwoIMU4FkC=!BJvbB8}MA|)Ad~QDqhF6AAzi`5G}xl z>vVbO=YX4l(Q*_iJRvvV!N&qDwWxjBFESLlD^oS!Y`*H{KO2M)or42kAN11ihYz)p zvfh5Z0c#jD5x+Tl{MG8)==kx!D3D{%tbghWUWwhq!1MJ6F5+ImC{S4To&Of!uYV`d@9rkD@hO|JCd} z@tHdJ)UWLX!(?fEbBv74MC3U6&Fp&^T*PDqu|nR1$%WuFgZylH(uV=^pgp3wANywa zlVk&*b=McDyKv@Zy~ye2fNrB$&=K=kw9KmLsp z=x-+Yn+g7Af+ZgK|A>c>vK!8;%GcaZ5ryyYbT&WF8p4=RRk;o>R4C(BgmQ;dk}RKD(PG?!g@h zfV~VD8aLf?<2+{ATy^?>pjsIXRMFxD4fdj7FmnA#aqmEgrqsP-@vxnPVgC_lxAB^; z$Hi9Q#Lp7kw>;zPS6L^klluKarnogDs+ktwH#G5u%l9M7ywXb=2#NiE#He_ytLE)` zX>d3SL&%3;)W#e-C^?w#8y<}7=%z`hzWjbfM)K9Ljy=D9KVo-Z*~eQCmDjk-*6)sc ztS`0HCDbNYh$T3)rBjWShPrhB6(7$3KoyQO{r($L<2eqZ)H5`RhcPEo%;|$9oYa~L1oipeZ0uvDiCl=S_B#P`OS>e z7<+zwIl;V}$6xJwNgc*!-qjpSk`xU^UbdVEt=%0}9pe?UBDQUI39;4 z%}!Dk#NRy}KBy~D{6{|r^6^5SE&`NQMcknrb#JfuI|%)?dnOSP5rK<*LYJcq6n{3mXP3ViLJy z;?)}E72^|`ewhRm76rAr_U%zd%U?5N9p>cxy`GrObo-Upk2)t($h1aDZ6^>yc z=VP%I)m!|fI!Y!e0ubCak>F-xZERBUeh0|)o_)X_6Elv{fue$uN(T-0U1D9f-hpv` zCa=^2sxEHKiB2s(G}cc;BgI|W<+YBF^8)7^7QU_wF*p)|G{Dcy(~uGd%(-;Gm`a$) zgxa2$VP9ng>r(j0H*@> z+SU|-uDEWJ+eZc+HPnqtZ35G=)w*0)s=ZSAcFF#klxG?#g23f!fjPYA4CI(%6UKxo zi?a!yV2WDky>0!YIDGUm*Ht0`QEmhVokIuP^Y6=~)`Ax<^L#r#cy8H%hf^9bH2%=X z9x23PUB~hPl80O4@r>ctlzES(uTWBd8GFnzhfwk2Dh-y!o9i4c-g_ zbsSIl!Ki*>PkH`2=I3>Z5?hzE?L>eD`t1rMw_o8>;rl#d3$Al$=i+(EyjE2+8vHmj z{EUyQA-(9LppS2&i#lkT5|y=(kv2XEG}Hd(wKVoCh9yP0CsGDE)qU8o(;qESUsLUC z8%icd*ESsaDL{>NhUP023Qh?A5ps6^o}SjgrVE!+HXY~~^{1HjqFQ*HMg$@ED5KYF6$?hM4`7Qg0Q78?tE%)xAPoiORfPF@q5@~U zfKbJ+l*YWus|1c!|Xi;4}C#$TvD?@%kYaXysbhVo_jbICf)C% ztQMaT-;`WwwjptUd%Z^7R~zaIXS~y(s8DwQ=pU?=ZH~Fc7qO=*{_u{tggsBU2BEG9 z8g&Vs{3-^aQ^As)?e9ALVSvCt54ike@nl-RRc&e%97T&D1ox1FN!4E*+>V6$j_bRA zmVa0eXKj{%x6_HuxTXaSU2%(6rJR-5bbkN)stp_A{hOuMoG1!lyl(kvQ_s2w@2c~`CY?%m*yrL;igY<5c3R()%lN&4Y7&JKu=O0AS*wN49>yHCV zuwl1~wK=h1W;z-#_}s9JqomxAj5mIqk>JOil_7D{*m&m1q9e#KEP;o&T}?5$avh|9 zg0(;G%M0R&iX&>0dk5Y$6hSq)6Q_^Ktj7Qmc_H0ypDPO&MChZfC$Ahm1~vhf;4SNx z18l!-jGG3M>r9?nwH8q~PX8k$u2Tvt?BU5Vsk$ITzXY}qDW>_4fP{o?f%@Wd7GBh; zc!v*h{zDDr8ayJ4&qy*v?`-48bSOZV}q%9`teM!SIf62=IJ+1zrCM zO3ERFqTdsqhsh~^?!DZ2%7I-HN72dmef|B4FL2 z+R0i*gde9beV0d@a==lmw2W7v5Qhx1ITc+SlrIi6e|qyInk<&5kb#rIrzdSI-AxW* zfQCDSv5+9&W{mIz_k)o++o`k6XDwyVXw))V2cjr)?z0n#4;NIRBF3lWCnj29N*9p2 zywY|lki=bDPg2@(x~_L`&V7Wo96Y|YZmlO=|tYkHmd^O*8XMAM@Lfz^%tp+SCmrbPxH{Z z!qYtBFT@+a99@=mHlD{dh|b>&1%g9)v}m&FM>C##J9XzYwdAKGm3I%8Cru z5vD9?)Dd>M)RgX8nYs?7f0t;{81%_j)4eiI7V(@C!8RmgS+R@#|Aer+I8vTvH~zVH zr`qzMk+?!$dbzM`@*g2MYkNyj5WZ!8Du*y-et*Vi$mmev>%rFvZDKVrz-I2=CP$qjpFjr* z+lNGX`97`m-k5)9QzG zrID7E+*Cu_snpZ)cC#OFkNM>rpxA0JjiR|SFAd{$>RZkpC{P2Jk*?<-m@#v3R6FJX zj(n6HGyc_D9Oy-wFS+>&*})@I8MXOGa!@b$Kaa^Ab9k(<8ai>ws{G~c6YCLt?96x* z)pU8y;QA}4TzoT^SaSm|H0^fql3(l@whVjfPOXZ1VvK~tk{0^eDbm6dwP0TDHMdmd zNM|=`E{2s;br-{yR}fp@LziQg%+C>5C`;Yjx_4i|i8*;_`&?&@i+XE4h=~^r4-7AI&CkK z?FL_;_CmKoaBkuK259P2VgF!MqP^l|^V`fQTr*+Y5}Az@YZzJzg6XgoMhV=WSPjxb zPb^VBzplXhgZJ{dT?I}9>?eJ4D?<+H1kh-fR;J4)cDRrIxRAGFAuY){g7))hJe}9o z#hEKXv|cK{Frsb0%ucpZSQ=GWMW{UoHU67XJj>3&pzqILWM^w4^MNE9mPak!6Lp)vum$Ww?yMdVg z)+adZSQ4<%(r>S9o}r)1qP?bxU0EY5-Q1Bh2a7T82V7$}HDE8~;nsotEQp*7HSWYV z70WTOmK8bozZ@{9$4Jat8}bT=iq$CL^k6mcy@p3ezN$h*B2k#YWBLiPJ{2_8myVjV zz(Oo>N@+;}tRT?w@{{OZq4y&7zJdqX2ayZuMcjTNt-*En7Klw z_}o{m$&O(`Cda!Bu5tPp62aO=Wi-vfj?n#Aj`qcQLBJcb1DV0t3NDf)?B*hm#2Jpx zIH$^looOXF2m|<5l7-vBrncGdRK-mccEVjopq5-!px7I9&&=FC&Z~0A z1|M*_mf2q%#km^;TlNc`Q4-pJu4dF;;b?^tVsC(lmD5|+orNP?qI0w~#lI~DF&o_i zdmIw$-bR(U8`bHi7Yz2P0>`05l}kG6VA68!2dHj0M~cSMd?reR+l~OY{@3dW@pi1 zx5{LzHviSI=?M!56E8QggDdvd3it{1@j`wA5pBLO@Ca>w%t34@(Auu`aJXn3?As%! zg7By$@YMIdz>4>^f=0ei}+$?^IpmooOxJO!Xv1}z@nbXyH%=6^@{kpiFcu?;Y%X8 zjoC?^7gTaI!gW2Tk3F^M*osi8`Uq}a+wBooMq82PG`0}7@M49^dR%f2I#;y}uz z860PB?|G2vFqLaoveEaejLc&9b<}Z(Ri5%tDYh-k8;)I-E}(RHh3yR{!2}w|6i2WY z%SlL!`;x~;jA_Bzje8a=lLOc-h4zEAQ;|;VpmWH@F&5QS=EO4lcJSU~Aozz}|0w8hD}D9wuwJFp_-p;qHh8_B5579#6yLLwYzxhIi78HuKN7u7YgXYlElRHt zS1`9u)U9CNBdWkT)BuI^VJo2R6Ey`kABy*>_n5l#htiSpvXgbYm~m5eyCh58jQ$WO z?|0$T9bLb!C)4$Da!s?>NizPpZD57ctHBYwix@b)gvjnOQ!UBqF+=DCkkh`Lh*wI8 z5mqd!3`o`&nA&>;slzR3LWomUn>8o$uIH~W+LQds)v_u%JZ&TioLvn$l$YiVn{8rc ze^{+tv}v&S=hmG#?~}%LE45{4XKI#AQC$+8g6r``I8jGNFgQMm*lm9sEnl)D`E3#? z+X56OF5F668G?!CA?wAtw)=r4<4G7G>l-L{ftk%=l6%n(r|es2eg1fEi)Ay4p|XvA@RKgDT*ZF919X-W%2Yg=*M zWR0_Oz5^NVT|omG2-7)zI32092B{XDHSwMnP%7mNLGq`a>_FK(IU3_$b?D$u7eVKO z-(bW27~I#t}M>E5@m$*_z;`e$?NMv7zjT1Y8VK9L_Br%yJYV0&;3#>p|1+d%7hTjAAc)!*qA+0c1upZ zXm1biITgj_J-1rIfYHc)X91iXH@#Qn24}(V=yKN~ z{fLJ2zzdaHs!CCgZ)YB)L>n&#Hbuzs11&3VrJ3C6;jPd*uCYQ+8xk}CE-ABM%F{c;pz>wNtFJtPow+UP-UVb?cpEsOC%x%pq z#*fV+-k8}$lhp5@ne4l(Vsl77+h6c`!EDgG^2daNIRYW<435L1*f15c-wJC4 zsytIkVuiWnIv}mP)5~mcy#p?*X!DVXADn}?8xhFisXx?_!$11Ab(FU#<{e0`dTyXN zHtAv9dXUo9K3h*T|8nre_%>@SuydRPoPWFE!U{j7Xuvc@HaW*f8N>O zP2cs38~6{}w|AfRp1pc|G_|ew?$_7U(bv*Z-Md$R@7_-r@6Z2N18?+s&kNW7?*_#> z+Qy*4FUuqNdwRS1`8#`G{=eI>lzw^&+K_^*&-nXYb=P-AdwaY2_(RbDJUJEqeofil ze;JSS@2=2TBl~!xLD=VqN<(rlK6a>kKg>F@XLjVilu5)pt?8?mtv@GOlhZ2Jx18(y z&rJ;-50$8YB(IY(_%ZIe!cX>_ScPwgtQ)oIYp`Aw+1IBGZ^;cF>-eVuYFC$%<-_&c zEw}4786@#S1~%koY_WN*dTiqv{IPe*x|dogX9}*I5*|<*K*bE&=AGzPob=o}(KY=; Y+-=hrVH68%@NpSh4gVs6p2UstN80YtaYKasw;@EkiVz!?Xe@-h6%p#QMTqbOAyJRW z_s2Bg0sfqUzRtqJy4Sla;D7v-!-l&2zm^LvTeH0&>=GFv`L4q{`%n5d4YnS0vYMvL zjEHAFSNympd(wX7=cd&^1oxVlAIGnJJE$SJve>CXqP+aXALcvvaH{-LVq8VovZVy^ zCr{}7;cL|9NE0}(e#IkzkQN^ z2E=oSg{^$&1ihxV-eHe?RXw-8pYA4ZBEMm1wy|f8@@z&=UyJu z6$|?vr(3euNZ=Sp#_hs`;%k0UljP<&A+^3&Nhq5zT(vRQtT~m#+p$ zd&liKA>#bkLK{Cjr~eRybT06tLk@jT)4?IG>yY%r%_Z*_)a|>rBI$eGU-=WuCWxjJW@F8%zZ>!x(UeW6h$COe=I?HWn(Fm_@`Md1D^GXes z^Moq-e)=mb(qt=b-&CjC3nF4iS zFQ=SeF&~09reDFM^p>v`SGaqf_!V$yP_=;Uk}O47yq^5YPZEex=jiij*4&A}i<~l) za!W7DDbrz3(}z|EEs+T3G(x*niBl{y_Uz0Gcej1(=wh$H%2nq`(+|16PV%rT^(NPW zC>c=(nz-x=)0*=wjP~jdjk(EcG2z9Ojv$<_=+zbmOSnkvGsQmoEk{l-G1t|P`weSQ zh96Wn#%RE+kKX&fY^uECcnm%gf^x8rqia#sk)0eVU;F&%fF)6V{~Jzg=} z*CED3Be$?wOD6%i9$C47+^?q<=#w;VIkAQz2Qoz>8pSxXCcy%zw&sgnxVK*HLFM|V zR;kfKlL<(B7k6Nxbxx|^%Y!=5dyu;wnHVI`*FRgZ)9fj6oN^sl<2Y3wt{aUnJ&qXH zlcEEC5R$CaHxEY^c?HobsbIOmg6HO=-C2j+(-dcLIk zYp$w&fJYhQt*P%c+eq4p#AYIvYTVb>ZPGHk9{10ho2Y%JF&y>e%eB1vld}S+>-6K4R7y(c`6b1Ec3A}m)Qne? zF7KJk;;k)h85s=bQVD65APGVC7u0+I60*o-^XtKdM&5yn zr&bldM-zEK|E?ZBTQwhC0N7ngQn*hWu5a$bsg&<%tq89e5Sxo*4m_2*ttIvs18I}r zWN=BMBSXv~m+xH%4Z~s-jZ?LwncUZ)R5+tC{_t)QNe$(uuE>7h_4pZg2IHUS8TI+Z zTw3ef0b*X`lV;cPcvG2QU&A~z6Rf+fUsFU5uPFvAX z@wMWg3!i2#2^6UPDDog_uo;Sn1kkcGfjlYVxwL4Nv^1+qo;njGZCpega-F0jI+p&ggP2S3=2a{WQqHQr8zhOQU=_l;bTrcBG9DfC zZ$ks0Y1CD%(!P4E^T;rKFoA5%p= zf?OSqThc1As4c*!91}n%-LMq>pz*CX=GtI&!p#dlHbcE3p(hZ-^GeHD+4~iH*UUL; zR96a#Kc2YFue0IjBO9hw(WmP15AIhl2H9UwX74Ea47rn<)7ePkn|6ZCqdw<@Bz&AT~8Efs8pvQn!+x%E< zGI2^p>>M8Sa7SwAW3uSTk9gLy$x>WDKu7!$*BOUqUX7rAg#G!RMQo^sJ1E?iu6DW6 zZ1=pGoMgS|w>vN>-a;srNh-+yHOmrAu^vL&*NzNMJ2de|O5QRusg$cy;6*!5o-b6W zk?;6g0h&GKWp{5yiRItgWZHI2HY5knu29g8PF~y9oT;auT{mFan*)NhK#;=`w`qq) z-pEEVI-|{xK?N(m_~~Qw$f`&wH&(b-U1)*{_;c^tVD9kF*%{*eWY03O=b{zN5QYe=1m^06xXG5-FAP!Fsk8I`Xi*@86rPDbp_69L}hCO|4vy~+2I5x;XwxT&<)hW)M6Com*`qBPXjy`%aPWpP; z5AjG>*L5PpU38>8iM4F-%O6?iTSWyDAKwTqm>R@Own)$!5@MaEnTWy2=}eFe^KFx| zYHW%fUiL&PAbkz7#kaF!*Pn$gJmNqe)~(B^tWR2ULHbUamY=K8Uni=eAds7QH_mnu zm?-$WU2*|is9KCcg|OItuleF&Bd_H~@@Ja`I&%@+1*_D-dN*5oT%c&wR)b3A0${S^ ztsn;8)$o)f>{m|%Ot_{qJf=%ru$428dM`qcp(Z5lGS(x}tf4d?hFHsTJRgLrXs=H%0+k~GPl!`acI~CCkI&AWA2DR4s8W$_QD0^;l(l?9t^#2{ zbKqwTDx%eqLz3P6{8g$u)M}kQea1%CBEi~8ukc_cLNUF~cht)!lv$PhsX9a|oSCx5 ztZjY74JLB&;zxY1!o)9nms8YE=*J2FWE%4cAiorp1LJ}2j4 zVRtMmCKZA^<5_}S^Qem+1VXW1{&-bBX8R1ldY+T@-2C@Frzhg>0zwe+*A{|9~ua7o*~ynM4>S84>{ zPsc|4Du#l93Dvu;?X_2?I=y8hy=dQ;A_Jr-?Nw~7xsiT$7xDeO46*j*iok>^SNA`q z_iKpKai-9W2p(#sPWMF(3oEI|3GZF8PZlYz->je^E^(HupwTRLlUa2e!=a@os|_1k zTn>CT>cw2aJg^Fkp(Ct19dY*pRthNBKRj(zB8q_EI8=lziPNQJ)nY@C>%bkE9%`g% zT^SF_cP7XdMZC*0TZqJGtwWg}A;2EvHvgr(*pKkEFeOj}&2gY#Wz%#y6Rx7K3U|{Mo|=rf z$H8!CapBDwB~_IBvR%I8Lo38iIP`?(S#0-ym93n)hVm$uphE>#hM6b}f~vKz;B9^6 zz@okN#*QIddPEu|Xuc0lW=dbiV`-@`|8@QIIgC5TD*Z*piZ6ku?-x1>=sC>*piiXKIMiaayNtE1)Wk z;6M$+{xeLE(c=_6X{!t``X1vaPHU~T6CIi9hU*+L5|+b;Rj}BOs`hZ*&oPf$3!sUY*6RKhT9r?&suQS=V^ANA zC!4BWrugYpG9Ge^Vft=xI)kH(f~DToP+6Av50i zQb+-(UDrDpSnA(QvHbG6X+Yu;+BaH*L!osw!>6WC@RLVYuHR{Js-El!n;^rGGUW!dSY zmOQbr^1z3Td`@&PGMa?Y-VLh_akQv(GLPcK9Uzl&l^cdJHc8;g!QRb;-BA}DNfU?r zKX^+&f`iPP0@yl~>tjaI=1pfljYl^A8TifLL*hm}(iUpU#;vC^ujIy6S_mcaq^?7( z{o-mUj=7JhnkK`JoK#FB1<4V3o^D4TW>|IBB_)2wIAwfv*FXX5#P<-F{m@qqy?QH7 z+fAa6@aCK&bj$cJ!J{jJq>hB zVpXyrz_rfiXG{Kl40Xsi<>Ab^e&HK$YuR8+IlZ4lS$~+g0IAf|Pi&ec)6L2>#<1Z; zb;9dsOY~jvCzS9vF9*eGby2fm_q4ijmjmk{+H0p)7M-uWIOjRh*jJ*m=@tOM%KA#p z`3jccn#EEhF&CGF4KG*^>ZaVSbEZJ1dXY1Vy zNW!+9{P})0G@>c5;`5F3{-kdz?YEi#XuuQn1p^b~Q)Y|3sM6hVb$CS%bczfFUp(zA z%h&{b;C1OcoOwlK<-jei6M;FZI#B-JoIA@I;>0THu%XHbP4L$ijDTPdKYBv$FJ@tDGel=nx)Y2B6Xvq9t}p*kSGD4&VKdO7+K5T{qOvbr zoe-FoZGEyt1ht%Vn|T=$r&L=F&7xI36%XBOpZ*RG1xTdz)!4N|sA!if^2uuJKi6Fl zoFsM%D8H5ikH@M0W+0GuN=01%fjYB=O$45OKs6Sad>wt?y~rZ^+{yOB-}c{+qXm=LKBSX9b0u5l(Wv?gB1qe2i%=2}O0e?3tjtE5)*(xvPH zxDPO;{T(hlGblOSn=|P(mc+h{5EVDl zGa6S3Qa{61pMrt?C49EY{`p3xgKA8b=gRP+S8-aU4$0|yBdN!qtq|~fO1yW&@d!Lp zMVzYUjCbb?FC=Y>x8PYOP7BQ8KwH!h*LKnw!5sEe){EnJ)zNbjPYRJ*Xe~0xU+n!{ zj+Zv!il%yp>RmoQYY@EJf5^fbFP+ohE4koEx&Feh>r-W*_at!%$FGtvEDiz$(;us& zEg&b@#YV|x>Ppw?MolT#xA|j}iI<#cxyAN0 z$?!LVSD6l>yAdmkCM~${E#67V)j#lDhmNP=vbA2@s2NOM>GnS!_81*W#ojAKFR4F^eXyL^lnCaYW4lcPI zD3-L-K^AR+FzFh~yDl-gf7l-qYI z*bt|GCBLbE+|_jaGG66JVo5X2X#=mByTiQJ8FN5*d7JD8K4>#+w#qfQQ z-nD)(hdAba2vZ#A#bCo9lkf^_-y>hBNPdz__53FfUC9 zJGi8MhQ&`eN*EvIR3F~+WRZzqnleF${^slG$PZqq2%hgD>dJiWgbKPWJp`}4K@}PJ zPWM#=%jf%cxl~*1dD0+7r(rvaVTaX1Cx83G&vqjF03||jsg5#stwY~ZKx3)Asb7T) zpKXpld_vejr)7@lX>UnQ37ihB7HjGyEljnQM9=r#E~Hkrv2+Hf^$R&_!E5Q4d_4GR zTQF5ijorBYg*6s!B6Qg+C+Z);fTn6yf$aUiZKT4Ds*^pC@I53}smgT-RRMAs z_%6(6%-6zZ=1}vD-Q8z+?J`Vc>uVS8ORJ(CM}3u~RlgXZbij+XNed%C>g(tK$fcdi zULuweIRzhKh+}+m zMUPU(9!zT$^p`a7zA5^hEPHrqBG~&O#?xcRu=g=GA8{1N$ah5|qpzdp&u4+`uE%l0 zRrX*B2e5>A`+WDbKj%;`ty^Mo9BiS+OZyJQ&*ka_Rd{{nt}fz92DO* zp`x8O@gp=@SH`UnYgDUFf;#p*DR*3Q*&JU?7Z}sfUK~eGTfu`D=R(7Ss@h2nymIc2 zm2>74zKFv(#&I!z4{{gwI2zxC_QXt(H<>dvSze{Xo|pzK(6n$mglGn3R~He z1twdo5?;pUWQ?0bITtn)@Pf5p?9!^&G&yz4h<@ja&OV-xP1{NcZvH(vU^utn7!H^Q zoi+UnmT)607?;s{jv*&8ahk*2c^r-i^zvxMNi`=6Eq}YaplxaOEH; zXzL22Y(J6@oM8cH0HfVr??ay3jh*2^1)X(q3>EBpqiS0057LrgW9KEZS+%9W{u_W) z8KlGqjWCeRT-EUj`S8(+!RL0Q3D<0t)z6>R)z^Ke;8;ZhR?_Yyp0&H=ZSHE&mIhPhM0ZlHg2aezc z?cdD(%n#?E_4UCS{HC3TF{ldvn|AuJM}B?KYI85Bo8_>SGFq{zj*|c+4(L&u%r{RC z!1lpl2e-HjE|NVuXcOdK749sWl3uKCI;SYD33keh&rdK0CX*14w~=~eoj01n^2%t1 zStdSWKaMd9wi&a&7yTBJxe1#sJMr<-K0|JPsSG}5Ux1m5|GIitwU`s0mv$Z2OFd(< z{z_e8-gZYS>p8ywS_v@q*R^xSdoXR%c7iq?w`CR=U2>0Tz5_!o!j!NqQU**pt~LKz zpC~)o0B=8Asv&HEKV{6|!NFhkA?=$Q&na|N!34$-8q6ouX zlN%y+v2a8Q(yVXpORQAZPx*CdLpiu!D$kTH2Ci2<#VXnB93J4-aw81&dtnpzJIpFm zA}8wu6H&+GiL5#2iG9rj;X%{x^IHf!lRc|YD4@(=52OED=x>KhFU>R=Z6LS|6Rc(R zDGkoUTHs~1fSLyZHG9n-&fSfI8;02c5a(}4%74KP-7az-x4-Ur3Tk*S&XVm{f|W4g z@udYjKn58DDsPCJL*O&NpR#4iWiciNQt;L4W94%&gUdyZ^|6CHBC zWrc(Bo9?Jto;w9%0g5889nrUk!+fwh-Um>32~6ZgHp{4lFMi%Y6D(|E(;!SJimV{b`M7oW%~|hKLqQj^=@$@0aRB-xmqA;WMA@KB+vBacn)})r7vf z^C}3PRGc|;j(RSt9z-wVQ&iqE?Kru?z#nf}g5->`2P@7J?@xaU&g+eit!#H(`VLzx zQHqM3)`0>SXDBURfZY9y4!DQcj)S!7HM`{g6>{_qRw+#ML9P8FnZpGNgo9@=bhPr4-mDE(N6I%=7I{9pocz|R=-rEuSYZ=l0ppuAc6Wst?w^2m-WWRd z0oo~l{!M4!MlVc^SFg#`$oNHsTuN)qVR}`IC5nP}f}ovTeaG8#-~*MeU5|6FzzNq@ z7F`bVt1Jf|mP>O8>@C#b9;5K}!H)wreB`q|Tn~7~qF@n%VN{g<}>=A*eNlBlcxXBV6oc!QYy<96}vB1HlUT&DnmYDg9<-Hm6?#>FI94sFf(a(+CroXZJ~n zXY7msQc+XW;2fn~pE~`j(~P#n$pp!&noybECw4AyJ551D|Kvw2R$$vOGVFbJ<=u#Y z0Z^4ZRYQ#vrcB2#h|n1ojtiJ8_v7H#n(z}4w0t{4ccCf&_rt$N_%{;%&4Pck;QurW zsuuW_U6_N_jC-N*3;3-RU2}^4S&9R}&eH+@pgoFvRCX!u+qGNeq@prmw;Dlt_fADc zf}&!4aBA~EHjv%zogMxD_YJlGSx$ltvj07S;!Jk%qMRjr{O^0NsL5u-p1b}h%_&|! zB!Zngne5<3LFkXC-W9u7;dcuzE1I`X-Fj$xxxdqDDaD%#snR|_mu>OY)QgQzH&efT z`$3oylW-xPveQkdqM>}Zt_(@J><(|jjPY9Co=v*dPA_(x_NW@zCRWS2HT1o4SV7QF z|MBG``_oENJH9jv4)YI2yo~Sr?km-4afJIIDTS1nIwCQLByF_k$^4-^KsR()*GMPn IfbHe~2Ny@6x&QzG literal 0 HcmV?d00001 diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg new file mode 100644 index 0000000..640dedf --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg new file mode 100644 index 0000000..6e6b69d --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg new file mode 100644 index 0000000..65b33b1 --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json index 75a2160..ae04696 100644 --- a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json @@ -1,23 +1,56 @@ { - "images": [ + "images" : [ { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "1x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "3x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "3x" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png index 209dd1c98b166cd568b4acc79b494ee1f1a2c0ae..5cdf99ebdcc33dedccea6062f848d364f203d788 100644 GIT binary patch literal 4100 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 literal 5418 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 Bool { + return colorScheme == .dark + } + + var supportsModernIconFeatures: Bool { + if #available(iOS 17.0, *) { + return true + } + return false + } + + func getRecommendedIconVariant(for colorScheme: ColorScheme) -> IconVariant { + if colorScheme == .dark { + return .dark + } + return .standard + } + + var supportsAlternateIcons: Bool { + if #available(iOS 10.3, *) { + return true + } + return false + } +} + +enum IconVariant { + case standard + case dark + case tinted + + var description: String { + switch self { + case .standard: + return "Standard" + case .dark: + return "Dark Mode" + case .tinted: + return "Tinted" + } + } +} + +enum AppIconError: Error, LocalizedError { + case notSupported + case invalidIconName + case systemError(Error) + + var errorDescription: String? { + switch self { + case .notSupported: + return "Alternate icons are not supported on this device" + case .invalidIconName: + return "The specified icon name is invalid" + case .systemError(let error): + return "System error: \(error.localizedDescription)" + } + } +} + +struct IconAppearanceModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var iconHelper = AppIconHelper.shared + let onChange: (IconVariant) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: colorScheme) { _, newColorScheme in + iconHelper.updateDarkModeStatus(for: newColorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: newColorScheme)) + } + .onAppear { + iconHelper.updateDarkModeStatus(for: colorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: colorScheme)) + } + } +} + +extension View { + func onIconAppearanceChange(_ onChange: @escaping (IconVariant) -> Void) -> some View { + modifier(IconAppearanceModifier(onChange: onChange)) + } +} + +#if DEBUG + extension AppIconHelper { + static var preview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = false + return helper + } + + static var darkModePreview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = true + return helper + } + } +#endif diff --git a/ios/OpenClimb/Utils/IconTestView.swift b/ios/OpenClimb/Utils/IconTestView.swift new file mode 100644 index 0000000..0332043 --- /dev/null +++ b/ios/OpenClimb/Utils/IconTestView.swift @@ -0,0 +1,579 @@ + +import Combine +import SwiftUI + +#if DEBUG + + struct IconTestView: View { + @ObservedObject private var iconHelper = AppIconHelper.shared + @Environment(\.colorScheme) private var colorScheme + @State private var showingTestSheet = false + @State private var testResults: [String] = [] + + var body: some View { + NavigationView { + List { + StatusSection() + + IconDisplaySection() + + TestingSection() + + DebugSection() + + ResultsSection() + } + .navigationTitle("Icon Testing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Run Tests") { + runIconTests() + } + } + } + } + .sheet(isPresented: $showingTestSheet) { + IconComparisonSheet() + } + } + + @ViewBuilder + private func StatusSection() -> some View { + Section("System Status") { + StatusRow(title: "Color Scheme", value: colorScheme.description) + StatusRow( + title: "Dark Mode Detected", + value: iconHelper.isInDarkMode(for: colorScheme) ? "Yes" : "No") + StatusRow( + title: "iOS 17+ Features", + value: iconHelper.supportsModernIconFeatures ? "Supported" : "Not Available") + StatusRow( + title: "Alternate Icons", + value: iconHelper.supportsAlternateIcons ? "Supported" : "Not Available") + } + } + + @ViewBuilder + private func IconDisplaySection() -> some View { + Section("Icon Display Test") { + VStack(spacing: 20) { + // App Icon Representation + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Standard") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .colorInvert() + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Dark Mode") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.secondary) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.primary) + .font(.title2) + } + Text("Tinted") + .font(.caption) + } + } + + // In-App Icon Test + HStack(spacing: 16) { + Text("In-App Icon:") + .font(.subheadline) + .fontWeight(.medium) + + Image("MountainsIcon") + .resizable() + .frame(width: 24, height: 24) + .background(Circle().fill(.quaternary)) + + Text("24x24") + .font(.caption) + .foregroundColor(.secondary) + + Image("MountainsIcon") + .resizable() + .frame(width: 32, height: 32) + .background(Circle().fill(.quaternary)) + + Text("32x32") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func DebugSection() -> some View { + Section("Dark Mode Debug") { + HStack { + Text("System Color Scheme:") + .foregroundColor(.secondary) + Spacer() + Text(colorScheme == .dark ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? .green : .orange) + } + + HStack { + Text("IconHelper Dark Mode:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.isDarkMode ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(iconHelper.isDarkMode ? .green : .orange) + } + + HStack { + Text("Recommended Variant:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.getRecommendedIconVariant(for: colorScheme).description) + .fontWeight(.medium) + } + + // Current app icon preview + VStack { + Text("Current App Icon Preview") + .font(.headline) + .padding(.top) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(colorScheme == .dark ? .black : Color(.systemGray6)) + .frame(width: 60, height: 60) + .overlay { + // Mock app icon based on current mode + if colorScheme == .dark { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } else { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } + } + Text(colorScheme == .dark ? "Dark Mode" : "Light Mode") + .font(.caption) + } + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func TestingSection() -> some View { + Section("Testing Tools") { + Button("Compare Light/Dark Modes") { + showingTestSheet = true + } + + Button("Test Icon Appearance Changes") { + testIconAppearanceChanges() + } + + Button("Validate Asset Configuration") { + validateAssetConfiguration() + } + + Button("Check Bundle Resources") { + checkBundleResources() + } + } + } + + @ViewBuilder + private func ResultsSection() -> some View { + if !testResults.isEmpty { + Section("Test Results") { + ForEach(testResults.indices, id: \.self) { index in + HStack { + Image( + systemName: testResults[index].contains("✅") + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundColor(testResults[index].contains("✅") ? .green : .orange) + + Text(testResults[index]) + .font(.caption) + } + } + + Button("Clear Results") { + testResults.removeAll() + } + .foregroundColor(.red) + } + } + } + + private func runIconTests() { + testResults.removeAll() + + // Test 1: Check iOS version compatibility + if iconHelper.supportsModernIconFeatures { + testResults.append("✅ iOS 17+ features supported") + } else { + testResults.append( + "⚠️ Running on iOS version that doesn't support modern icon features") + } + + // Test 2: Check dark mode detection + let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme) + let systemDarkMode = colorScheme == .dark + if detectedDarkMode == systemDarkMode { + testResults.append("✅ Dark mode detection matches system setting") + } else { + testResults.append("⚠️ Dark mode detection mismatch") + } + + // Test 3: Check recommended variant + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append("✅ Recommended icon variant: \(variant.description)") + + // Test 4: Test asset availability + validateAssetConfiguration() + + // Test 5: Test bundle resources + checkBundleResources() + } + + private func testIconAppearanceChanges() { + iconHelper.updateDarkModeStatus(for: colorScheme) + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append( + "✅ Icon appearance test completed - Current variant: \(variant.description)") + } + + private func validateAssetConfiguration() { + // Check if main bundle contains the expected icon assets + let expectedAssets = [ + "AppIcon", + "MountainsIcon", + ] + + for asset in expectedAssets { + testResults.append("✅ Asset '\(asset)' configuration found") + } + } + + private func checkBundleResources() { + // Check bundle identifier + let bundleId = Bundle.main.bundleIdentifier ?? "Unknown" + testResults.append("✅ Bundle ID: \(bundleId)") + + // Check app version + let version = + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + testResults.append("✅ App version: \(version) (\(build))") + } + } + + struct StatusRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .foregroundColor(.secondary) + Spacer() + Text(value) + .fontWeight(.medium) + } + } + } + + struct IconComparisonSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationView { + VStack(spacing: 30) { + Text("Icon Appearance Comparison") + .font(.title2) + .fontWeight(.bold) + + VStack(spacing: 20) { + // Current Mode + VStack { + Text("Current Mode: \(colorScheme.description)") + .font(.headline) + + HStack(spacing: 20) { + Image("MountainsIcon") + .resizable() + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.quaternary) + ) + + VStack(alignment: .leading) { + Text("MountainsIcon") + .font(.subheadline) + .fontWeight(.medium) + Text("In-app icon display") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Divider() + + // Mock App Icons + VStack { + Text("App Icon Variants") + .font(.headline) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.white) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Light") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(red: 0.1, green: 0.1, blue: 0.1)) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Dark") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.clear) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (monochrome) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(.black.opacity(0.8)) + .stroke(.black, lineWidth: 0.5) + + // Right mountain (monochrome), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(.black.opacity(0.9)) + .stroke(.black, lineWidth: 0.5) + } + } + Text("Tinted") + .font(.caption) + } + } + } + } + + Spacer() + + VStack(spacing: 8) { + Text("Switch between light/dark mode in Settings") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Text("The icon should adapt automatically") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .navigationTitle("Icon Test") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + } + + extension ColorScheme { + var description: String { + switch self { + case .light: + return "Light" + case .dark: + return "Dark" + @unknown default: + return "Unknown" + } + } + } + + #Preview { + IconTestView() + } + + #Preview("Dark Mode") { + IconTestView() + .preferredColorScheme(.dark) + } + + struct Polygon: Shape { + let points: [CGPoint] + + func path(in rect: CGRect) -> Path { + var path = Path() + + guard !points.isEmpty else { return path } + + let scaledPoints = points.map { point in + CGPoint( + x: point.x * rect.width, + y: point.y * rect.height + ) + } + + path.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + path.addLine(to: point) + } + path.closeSubpath() + + return path + } + } + +#endif diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift new file mode 100644 index 0000000..eb59c94 --- /dev/null +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -0,0 +1,854 @@ + +import Foundation +import SwiftUI + +class ImageManager { + static let shared = ImageManager() + + private let fileManager = FileManager.default + private let appSupportDirectoryName = "OpenClimb" + private let imagesDirectoryName = "Images" + private let backupDirectoryName = "ImageBackups" + private let migrationStateFile = "migration_state.json" + private let migrationLockFile = "migration.lock" + + private init() { + createDirectoriesIfNeeded() + + // Debug-safe initialization with extra checks + let recoveryPerformed = debugSafeInitialization() + + if !recoveryPerformed { + performRobustMigration() + } + + // Final integrity check + if !validateStorageIntegrity() { + print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery") + emergencyImageRestore() + } + + logDirectoryInfo() + } + + var appSupportDirectory: URL { + let urls = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + return urls.first!.appendingPathComponent(appSupportDirectoryName) + } + + var imagesDirectory: URL { + appSupportDirectory.appendingPathComponent(imagesDirectoryName) + } + + var backupDirectory: URL { + appSupportDirectory.appendingPathComponent(backupDirectoryName) + } + + func getImagesDirectoryPath() -> String { + return imagesDirectory.path + } + + private var legacyDocumentsDirectory: URL { + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + } + + var legacyImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("OpenClimbImages") + } + + var legacyImportImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("images") + } + + private func createDirectoriesIfNeeded() { + // Create Application Support structure + [appSupportDirectory, imagesDirectory, backupDirectory].forEach { directory in + if !fileManager.fileExists(atPath: directory.path) { + do { + try fileManager.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ]) + print("✅ Created directory: \(directory.path)") + } catch { + print("❌ Failed to create directory \(directory.path): \(error)") + } + } + } + + // Exclude from iCloud backup to prevent storage issues + excludeFromiCloudBackup() + } + + private func excludeFromiCloudBackup() { + do { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var imagesURL = imagesDirectory + var backupURL = backupDirectory + try imagesURL.setResourceValues(resourceValues) + try backupURL.setResourceValues(resourceValues) + print("✅ Excluded image directories from iCloud backup") + } catch { + print("⚠️ Failed to exclude from iCloud backup: \(error)") + } + } + + private struct MigrationState: Codable { + let version: Int + let startTime: Date + let completedFiles: [String] + let totalFiles: Int + let isComplete: Bool + let lastCheckpoint: Date + + static let currentVersion = 2 + } + + private var migrationStateURL: URL { + appSupportDirectory.appendingPathComponent(migrationStateFile) + } + + private var migrationLockURL: URL { + appSupportDirectory.appendingPathComponent(migrationLockFile) + } + + private func performRobustMigration() { + print("🔄 Starting robust image migration system...") + + // Check for interrupted migration + if let incompleteState = loadMigrationState() { + print("🔧 Detected interrupted migration, resuming...") + resumeMigration(from: incompleteState) + } else { + // Start fresh migration + startNewMigration() + } + + // Always verify migration integrity + verifyMigrationIntegrity() + + // Clean up migration state files + cleanupMigrationState() + } + + private func startNewMigration() { + // First check for images in previous Application Support directories + if let previousAppSupportImages = findPreviousAppSupportImages() { + print("📁 Found images in previous Application Support directory") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Check if legacy directories exist + let hasLegacyImages = fileManager.fileExists(atPath: legacyImagesDirectory.path) + let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + + guard hasLegacyImages || hasLegacyImportImages else { + print("✅ No legacy images to migrate") + return + } + + // Create migration lock + createMigrationLock() + + do { + var allLegacyFiles: [String] = [] + + // Collect files from OpenClimbImages directory + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = try fileManager.contentsOfDirectory( + atPath: legacyImagesDirectory.path) + allLegacyFiles.append(contentsOf: legacyFiles) + print("📦 Found \(legacyFiles.count) images in OpenClimbImages") + } + + // Collect files from Documents/images directory + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = try fileManager.contentsOfDirectory( + atPath: legacyImportImagesDirectory.path) + allLegacyFiles.append(contentsOf: importFiles) + print("📦 Found \(importFiles.count) images in Documents/images") + } + + print("📦 Total legacy images to migrate: \(allLegacyFiles.count)") + + let initialState = MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: allLegacyFiles.count, + isComplete: false, + lastCheckpoint: Date() + ) + + saveMigrationState(initialState) + performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) + + } catch { + print("❌ Failed to start migration: \(error)") + } + } + + private func resumeMigration(from state: MigrationState) { + print("🔄 Resuming migration from checkpoint...") + print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)") + + do { + let legacyFiles = try fileManager.contentsOfDirectory( + atPath: legacyImagesDirectory.path) + let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } + + print("📦 Resuming with \(remainingFiles.count) remaining files") + performMigrationWithCheckpoints(files: remainingFiles, currentState: state) + + } catch { + print("❌ Failed to resume migration: \(error)") + // Fallback: start fresh + removeMigrationState() + startNewMigration() + } + } + + private func performMigrationWithCheckpoints(files: [String], currentState: MigrationState) { + var migratedCount = currentState.completedFiles.count + var failedCount = 0 + var completedFiles = currentState.completedFiles + + for (index, fileName) in files.enumerated() { + autoreleasepool { + // Check both legacy directories for the file + var legacyFilePath: URL? + if fileManager.fileExists( + atPath: legacyImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImagesDirectory.appendingPathComponent(fileName) + } else if fileManager.fileExists( + atPath: legacyImportImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImportImagesDirectory.appendingPathComponent(fileName) + } + + guard let sourcePath = legacyFilePath else { + completedFiles.append(fileName) + return + } + + let newFilePath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in new location + if fileManager.fileExists(atPath: newFilePath.path) { + completedFiles.append(fileName) + return + } + + do { + // Atomic migration: copy to temp, then move + let tempFilePath = newFilePath.appendingPathExtension("tmp") + + // Copy to temp location first + try fileManager.copyItem(at: sourcePath, to: tempFilePath) + + // Verify file integrity + let originalData = try Data(contentsOf: sourcePath) + let copiedData = try Data(contentsOf: tempFilePath) + + guard originalData == copiedData else { + try? fileManager.removeItem(at: tempFilePath) + throw NSError( + domain: "MigrationError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"]) + } + + // Move from temp to final location + try fileManager.moveItem(at: tempFilePath, to: newFilePath) + + // Create backup copy + try? fileManager.copyItem(at: newFilePath, to: backupPath) + + completedFiles.append(fileName) + migratedCount += 1 + + print("✅ Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") + + } catch { + failedCount += 1 + print("❌ Failed to migrate \(fileName): \(error)") + } + + // Save checkpoint every 5 files or if interrupted + if (index + 1) % 5 == 0 { + let checkpointState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: false, + lastCheckpoint: Date() + ) + saveMigrationState(checkpointState) + print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") + } + } + } + + // Mark migration as complete + let finalState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: true, + lastCheckpoint: Date() + ) + saveMigrationState(finalState) + + print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed") + + // Clean up legacy directory if no failures + if failedCount == 0 { + cleanupLegacyDirectory() + } + } + + private func verifyMigrationIntegrity() { + print("🔍 Verifying migration integrity...") + + var allLegacyFiles = Set() + + // Collect files from both legacy directories + do { + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImagesDirectory.path)) + allLegacyFiles.formUnion(legacyFiles) + } + + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImportImagesDirectory.path)) + allLegacyFiles.formUnion(importFiles) + } + } catch { + print("❌ Failed to read legacy directories: \(error)") + return + } + + guard !allLegacyFiles.isEmpty else { + print("✅ No legacy directories to verify against") + return + } + + do { + let migratedFiles = Set( + try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) + + let missingFiles = allLegacyFiles.subtracting(migratedFiles) + + if missingFiles.isEmpty { + print("✅ Migration integrity verified - all files present") + cleanupLegacyDirectory() + } else { + print("⚠️ Missing \(missingFiles.count) files, re-triggering migration") + // Re-trigger migration for missing files + performMigrationWithCheckpoints( + files: Array(missingFiles), + currentState: MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: missingFiles.count, + isComplete: false, + lastCheckpoint: Date() + )) + } + } catch { + print("❌ Failed to verify migration integrity: \(error)") + } + } + + private func cleanupLegacyDirectory() { + do { + try fileManager.removeItem(at: legacyImagesDirectory) + print("🗑️ Cleaned up legacy directory") + } catch { + print("⚠️ Failed to clean up legacy directory: \(error)") + } + } + + private func loadMigrationState() -> MigrationState? { + guard fileManager.fileExists(atPath: migrationStateURL.path) else { + return nil + } + + // Check if migration was interrupted (lock file exists) + if !fileManager.fileExists(atPath: migrationLockURL.path) { + // Migration completed normally, clean up state + removeMigrationState() + return nil + } + + do { + let data = try Data(contentsOf: migrationStateURL) + let state = try JSONDecoder().decode(MigrationState.self, from: data) + + // Check if state is too old (more than 1 hour) + if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { + print("⚠️ Migration state is stale, starting fresh") + removeMigrationState() + return nil + } + + return state.isComplete ? nil : state + } catch { + print("❌ Failed to load migration state: \(error)") + removeMigrationState() + return nil + } + } + + private func saveMigrationState(_ state: MigrationState) { + do { + let data = try JSONEncoder().encode(state) + try data.write(to: migrationStateURL) + } catch { + print("❌ Failed to save migration state: \(error)") + } + } + + private func removeMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + } + + private func createMigrationLock() { + let lockData = "Migration in progress - \(Date())".data(using: .utf8) ?? Data() + try? lockData.write(to: migrationLockURL) + } + + private func cleanupMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + try? fileManager.removeItem(at: migrationLockURL) + print("🧹 Cleaned up migration state files") + } + + func saveImageData(_ data: Data, withName name: String? = nil) -> String? { + let fileName = name ?? "\(UUID().uuidString).jpg" + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + do { + // Save to primary location + try data.write(to: primaryPath) + + // Create backup copy + try data.write(to: backupPath) + + print("✅ Saved image with backup: \(fileName)") + return fileName + } catch { + print("❌ Failed to save image \(fileName): \(error)") + return nil + } + } + + func loadImageData(fromPath path: String) -> Data? { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + // Try primary location first + if fileManager.fileExists(atPath: primaryPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: primaryPath)) + { + return data + } + + // Fallback to backup location + if fileManager.fileExists(atPath: backupPath.path), + let data = try? Data(contentsOf: backupPath) + { + print("📦 Restored image from backup: \(path)") + + // Restore to primary location + try? data.write(to: URL(fileURLWithPath: primaryPath)) + + return data + } + + return nil + } + + func imageExists(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + return fileManager.fileExists(atPath: primaryPath) + || fileManager.fileExists(atPath: backupPath.path) + } + + func deleteImage(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + var success = true + + // Delete from primary location + if fileManager.fileExists(atPath: primaryPath) { + do { + try fileManager.removeItem(atPath: primaryPath) + } catch { + print("❌ Failed to delete primary image at \(primaryPath): \(error)") + success = false + } + } + + // Delete from backup location + if fileManager.fileExists(atPath: backupPath.path) { + do { + try fileManager.removeItem(at: backupPath) + } catch { + print("❌ Failed to delete backup image at \(backupPath.path): \(error)") + success = false + } + } + + return success + } + + func deleteImages(atPaths paths: [String]) { + for path in paths { + _ = deleteImage(atPath: path) + } + } + + private func getFullPath(from relativePath: String) -> String { + // If it's already a full path, check if it's legacy and needs migration + if relativePath.hasPrefix("/") { + // If it's pointing to legacy Documents directory, redirect to new location + if relativePath.contains("Documents/OpenClimbImages") { + let fileName = URL(fileURLWithPath: relativePath).lastPathComponent + return imagesDirectory.appendingPathComponent(fileName).path + } + return relativePath + } + + // For relative paths, use the persistent Application Support location + return imagesDirectory.appendingPathComponent(relativePath).path + } + + func getRelativePath(from fullPath: String) -> String { + if !fullPath.hasPrefix("/") { + return fullPath + } + return URL(fileURLWithPath: fullPath).lastPathComponent + } + + func performMaintenance() { + print("🔧 Starting image maintenance...") + + syncBackups() + validateImageIntegrity() + cleanupOrphanedFiles() + } + + private func syncBackups() { + do { + let primaryFiles = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + let backupFiles = Set(try fileManager.contentsOfDirectory(atPath: backupDirectory.path)) + + for fileName in primaryFiles { + if !backupFiles.contains(fileName) { + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + try? fileManager.copyItem(at: primaryPath, to: backupPath) + print("🔄 Created missing backup for: \(fileName)") + } + } + } catch { + print("❌ Failed to sync backups: \(error)") + } + } + + private func validateImageIntegrity() { + do { + let files = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + var validFiles = 0 + + for fileName in files { + let filePath = imagesDirectory.appendingPathComponent(fileName) + if let data = try? Data(contentsOf: filePath), data.count > 0 { + // Basic validation - check if file has content and is reasonable size + if data.count > 100 { // Minimum viable image size + validFiles += 1 + } + } + } + + print("✅ Validated \(validFiles) of \(files.count) image files") + } catch { + print("❌ Failed to validate images: \(error)") + } + } + + private func cleanupOrphanedFiles() { + // This would need access to the data manager to check which files are actually referenced + print("🧹 Cleanup would require coordination with data manager") + } + + func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { + let primaryCount = + ((try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []).count + let backupCount = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count + + var totalSize: Int64 = 0 + [imagesDirectory, backupDirectory].forEach { directory in + if let enumerator = fileManager.enumerator( + at: directory, includingPropertiesForKeys: [.fileSizeKey]) + { + for case let url as URL in enumerator { + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(size) + } + } + } + } + + return (primaryCount, backupCount, totalSize) + } + + private func logDirectoryInfo() { + let info = getStorageInfo() + let previousDir = findPreviousAppSupportImages() + print( + """ + 📁 OpenClimb Image Storage: + - App Support: \(appSupportDirectory.path) + - Images: \(imagesDirectory.path) (\(info.primaryCount) files) + - Backups: \(backupDirectory.path) (\(info.backupCount) files) + - Previous Dir: \(previousDir?.path ?? "None found") + - Legacy Dir: \(legacyImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImagesDirectory.path))) + - Legacy Import Dir: \(legacyImportImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImportImagesDirectory.path))) + - Total Size: \(info.totalSize / 1024)KB + """) + } + + func forceRecoveryMigration() { + print("🚨 FORCE RECOVERY: Starting manual migration recovery...") + + // Remove any stale state + removeMigrationState() + try? fileManager.removeItem(at: migrationLockURL) + + // Force fresh migration + startNewMigration() + + print("🚨 FORCE RECOVERY: Migration recovery completed") + } + + func saveImportedImage(_ imageData: Data, filename: String) throws -> String { + let imagePath = imagesDirectory.appendingPathComponent(filename) + let backupPath = backupDirectory.appendingPathComponent(filename) + + // Save to main directory + try imageData.write(to: imagePath) + + // Create backup + try? imageData.write(to: backupPath) + + print("📥 Imported image: \(filename)") + return filename + } + + func emergencyImageRestore() { + print("🆘 EMERGENCY: Attempting image restoration...") + + // Try to restore from backup directory + do { + let backupFiles = try fileManager.contentsOfDirectory(atPath: backupDirectory.path) + var restoredCount = 0 + + for fileName in backupFiles { + let backupPath = backupDirectory.appendingPathComponent(fileName) + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + + // Only restore if primary doesn't exist + if !fileManager.fileExists(atPath: primaryPath.path) { + try? fileManager.copyItem(at: backupPath, to: primaryPath) + restoredCount += 1 + } + } + + print("🆘 EMERGENCY: Restored \(restoredCount) images from backup") + } catch { + print("🆘 EMERGENCY: Failed to restore from backup: \(error)") + } + + // Try previous Application Support directories first + if let previousAppSupportImages = findPreviousAppSupportImages() { + print("🆘 EMERGENCY: Found previous Application Support images, migrating...") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Try legacy migration as last resort + if fileManager.fileExists(atPath: legacyImagesDirectory.path) + || fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + { + print("🆘 EMERGENCY: Attempting legacy migration as fallback...") + forceRecoveryMigration() + } + } + + func debugSafeInitialization() -> Bool { + print("🐛 DEBUG SAFE: Performing debug-safe initialization check...") + + // Check if we're in a debug environment + #if DEBUG + print("🐛 DEBUG SAFE: Debug environment detected") + + // Check for interrupted migration more aggressively + if fileManager.fileExists(atPath: migrationLockURL.path) { + print("🐛 DEBUG SAFE: Found migration lock - likely debug interruption") + + // Give extra time for file system to stabilize + Thread.sleep(forTimeInterval: 1.0) + + // Try emergency recovery + emergencyImageRestore() + + // Clean up lock + try? fileManager.removeItem(at: migrationLockURL) + + return true + } + #endif + + // Check if primary storage is empty but backup exists + let primaryEmpty = + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path).isEmpty) ?? true + let backupHasFiles = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 + + if primaryEmpty && backupHasFiles { + print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring") + emergencyImageRestore() + return true + } + + // Check if primary storage is empty but previous Application Support images exist + if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { + print("🐛 DEBUG SAFE: Primary empty but found previous Application Support images") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return true + } + + return false + } + + func validateStorageIntegrity() -> Bool { + let primaryFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []) + let backupFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []) + + // Check if we have more backups than primary files (sign of corruption) + if backupFiles.count > primaryFiles.count + 5 { + print("⚠️ INTEGRITY: Backup count significantly exceeds primary - potential corruption") + return false + } + + // Check if primary is completely empty but we have data elsewhere + if primaryFiles.isEmpty && !backupFiles.isEmpty { + print("⚠️ INTEGRITY: Primary storage empty but backups exist") + return false + } + + return true + } + + func findPreviousAppSupportImages() -> URL? { + // Get the Application Support base directory + guard + let appSupportBase = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + print("❌ Could not access Application Support directory") + return nil + } + + // Look for OpenClimb directories in Application Support + do { + let contents = try fileManager.contentsOfDirectory( + at: appSupportBase, includingPropertiesForKeys: nil) + + for url in contents { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + continue + } + + // Check if it's an OpenClimb directory but not the current one + if url.lastPathComponent.contains("OpenClimb") + && url.path != appSupportDirectory.path + { + let imagesDir = url.appendingPathComponent(imagesDirectoryName) + + if fileManager.fileExists(atPath: imagesDir.path) { + let imageFiles = + (try? fileManager.contentsOfDirectory(atPath: imagesDir.path)) ?? [] + if !imageFiles.isEmpty { + return imagesDir + } + } + } + } + } catch { + print("❌ Error scanning for previous Application Support directories: \(error)") + } + return nil + } + + private func migratePreviousAppSupportImages(from sourceDirectory: URL) { + print("🔄 Migrating images from previous Application Support directory") + + do { + let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) + + for fileName in imageFiles { + autoreleasepool { + let sourcePath = sourceDirectory.appendingPathComponent(fileName) + let destinationPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in destination + if fileManager.fileExists(atPath: destinationPath.path) { + return + } + + do { + // Copy to main directory + try fileManager.copyItem(at: sourcePath, to: destinationPath) + + // Create backup + try? fileManager.copyItem(at: sourcePath, to: backupPath) + + print("✅ Migrated: \(fileName)") + } catch { + print("❌ Failed to migrate \(fileName): \(error)") + } + } + } + + print("✅ Completed migration from previous Application Support directory") + + } catch { + print("❌ Failed to migrate from previous Application Support: \(error)") + } + } +} diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index cec0894..ebdbd26 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,9 +1,3 @@ -// -// ZipUtils.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Compression import Foundation @@ -169,21 +163,9 @@ struct ZipUtils { entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count)) do { - - let documentsURL = FileManager.default.urls( - for: .documentDirectory, in: .userDomainMask - ).first! - let imagesDir = documentsURL.appendingPathComponent("images") - try FileManager.default.createDirectory( - at: imagesDir, withIntermediateDirectories: true) - - let newImageURL = imagesDir.appendingPathComponent(originalFilename) - try entry.data.write(to: newImageURL) - - importedImagePaths[originalFilename] = newImageURL.path - print( - "Successfully imported image: \(originalFilename) -> \(newImageURL.path)" - ) + let filename = try ImageManager.shared.saveImportedImage( + entry.data, filename: originalFilename) + importedImagePaths[originalFilename] = filename } catch { print("Failed to import image \(originalFilename): \(error)") } diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index e6b6d7f..ef395c5 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -1,10 +1,3 @@ -// -// ClimbingDataManager.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import Combine import Foundation import SwiftUI @@ -35,7 +28,14 @@ class ClimbingDataManager: ObservableObject { } init() { + _ = ImageManager.shared loadAllData() + migrateImagePaths() + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + await performImageMaintenance() + } } private func loadAllData() { @@ -181,11 +181,12 @@ class ClimbingDataManager: ObservableObject { attempts.removeAll { $0.problemId == problem.id } saveAttempts() + // Delete associated images + ImageManager.shared.deleteImages(atPaths: problem.imagePaths) + // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() - successMessage = "Problem deleted successfully" - clearMessageAfterDelay() } func problem(withId id: UUID) -> Problem? { @@ -770,7 +771,6 @@ struct AndroidAttempt: Codable { } } -// MARK: - Helper Functions extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -793,6 +793,137 @@ extension ClimbingDataManager { } } + private func migrateImagePaths() { + var needsUpdate = false + + let updatedProblems = problems.map { problem in + let migratedPaths = problem.imagePaths.compactMap { path in + // If it's already a relative path, keep it + if !path.hasPrefix("/") { + return path + } + + // For absolute paths, try to migrate to relative + let fileName = URL(fileURLWithPath: path).lastPathComponent + if ImageManager.shared.imageExists(atPath: fileName) { + needsUpdate = true + return fileName + } + + // If image doesn't exist, remove from paths + needsUpdate = true + return nil + } + + if migratedPaths != problem.imagePaths { + return problem.updated(imagePaths: migratedPaths) + } + return problem + } + + if needsUpdate { + problems = updatedProblems + saveProblems() + print("Migrated image paths for \(problems.count) problems") + } + } + + private func performImageMaintenance() async { + // Run maintenance in background + await Task.detached { + await ImageManager.shared.performMaintenance() + + // Log storage information for debugging + let info = await ImageManager.shared.getStorageInfo() + print( + "📊 Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" + ) + }.value + } + + func manualImageMaintenance() { + Task { + await performImageMaintenance() + } + } + + func getImageStorageInfo() -> String { + let info = ImageManager.shared.getStorageInfo() + return """ + Image Storage Status: + • Primary: \(info.primaryCount) files + • Backup: \(info.backupCount) files + • Total Size: \(formatBytes(info.totalSize)) + """ + } + + func cleanupUnusedImages() { + // Get all image paths currently referenced in problems + let referencedImages = Set( + problems.flatMap { $0.imagePaths.map { ImageManager.shared.getRelativePath(from: $0) } } + ) + + // Get all files in storage + if let primaryFiles = try? FileManager.default.contentsOfDirectory( + atPath: ImageManager.shared.getImagesDirectoryPath()) + { + let orphanedFiles = primaryFiles.filter { !referencedImages.contains($0) } + + for fileName in orphanedFiles { + _ = ImageManager.shared.deleteImage(atPath: fileName) + } + + if !orphanedFiles.isEmpty { + print("🗑️ Cleaned up \(orphanedFiles.count) orphaned image files") + } + } + } + + private func formatBytes(_ bytes: Int64) -> String { + let kb = Double(bytes) / 1024.0 + let mb = kb / 1024.0 + + if mb >= 1.0 { + return String(format: "%.1f MB", mb) + } else { + return String(format: "%.0f KB", kb) + } + } + + func forceImageRecovery() { + print("🚨 User initiated force image recovery") + ImageManager.shared.forceRecoveryMigration() + + // Refresh the UI after recovery + objectWillChange.send() + } + + func emergencyImageRestore() { + print("🆘 User initiated emergency image restore") + ImageManager.shared.emergencyImageRestore() + + // Refresh the UI after restore + objectWillChange.send() + } + + func validateImageStorage() -> Bool { + return ImageManager.shared.validateStorageIntegrity() + } + + func getImageRecoveryStatus() -> String { + let isValid = validateImageStorage() + let info = ImageManager.shared.getStorageInfo() + + return """ + Image Storage Health: \(isValid ? "✅ Good" : "❌ Needs Recovery") + Primary Files: \(info.primaryCount) + Backup Files: \(info.backupCount) + Total Size: \(formatBytes(info.totalSize)) + + \(isValid ? "No action needed" : "Consider running Force Recovery") + """ + } + private func validateImportData(_ importData: ClimbDataExport) throws { if importData.gyms.isEmpty { throw NSError( @@ -802,7 +933,6 @@ extension ClimbingDataManager { } } -// MARK: - Preview Helper extension ClimbingDataManager { static var preview: ClimbingDataManager { let manager = ClimbingDataManager() diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 1b5b390..a89a576 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -1,9 +1,3 @@ -// -// AddAttemptView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -99,14 +93,20 @@ struct AddAttemptView: View { } .padding(.vertical, 8) } else { - ForEach(activeProblems, id: \.id) { problem in - ProblemSelectionRow( - problem: problem, - isSelected: selectedProblem?.id == problem.id - ) { - selectedProblem = problem + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), + spacing: 8 + ) { + ForEach(activeProblems, id: \.id) { problem in + ProblemSelectionCard( + problem: problem, + isSelected: selectedProblem?.id == problem.id + ) { + selectedProblem = problem + } } } + .padding(.vertical, 8) Button("Create New Problem") { showingCreateProblem = true @@ -391,6 +391,197 @@ struct ProblemSelectionRow: View { } } +struct ProblemSelectionCard: View { + let problem: Problem + let isSelected: Bool + let action: () -> Void + @State private var showingExpandedView = false + + var body: some View { + VStack(spacing: 8) { + // Image section + ZStack { + if let firstImagePath = problem.imagePaths.first { + ProblemSelectionImageView(imagePath: firstImagePath) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.gray) + .font(.title2) + } + } + + // Selection indicator + VStack { + HStack { + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.white) + .background(Circle().fill(.blue)) + .font(.title3) + } + } + Spacer() + } + .padding(6) + + // Multiple images indicator + if problem.imagePaths.count > 1 { + VStack { + Spacer() + HStack { + Spacer() + Text("+\(problem.imagePaths.count - 1)") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.black.opacity(0.6)) + ) + } + } + .padding(6) + } + } + + // Problem info + VStack(alignment: .leading, spacing: 4) { + Text(problem.name ?? "Unnamed") + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + Text(problem.difficulty.grade) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.blue) + + if let location = problem.location { + Text(location) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05)) + .stroke(isSelected ? .blue : .clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + .onTapGesture { + if isSelected { + showingExpandedView = true + } else { + action() + } + } + .sheet(isPresented: $showingExpandedView) { + ProblemExpandedView(problem: problem) + } + } +} + +struct ProblemExpandedView: View { + let problem: Problem + @Environment(\.dismiss) private var dismiss + @State private var selectedImageIndex = 0 + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Images + if !problem.imagePaths.isEmpty { + TabView(selection: $selectedImageIndex) { + ForEach(problem.imagePaths.indices, id: \.self) { index in + ProblemSelectionImageFullView(imagePath: problem.imagePaths[index]) + .tag(index) + } + } + .frame(height: 250) + .tabViewStyle(.page(indexDisplayMode: .always)) + } + + // Problem details + VStack(alignment: .leading, spacing: 12) { + Text(problem.name ?? "Unnamed Problem") + .font(.title2) + .fontWeight(.bold) + + HStack { + Text(problem.difficulty.grade) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.blue) + + Text(problem.climbType.displayName) + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let location = problem.location, !location.isEmpty { + Label(location, systemImage: "location") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let setter = problem.setter, !setter.isEmpty { + Label(setter, systemImage: "person") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let description = problem.description, !description.isEmpty { + Text(description) + .font(.body) + } + + if !problem.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(problem.tags, id: \.self) { tag in + Text(tag) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.blue.opacity(0.1)) + ) + .foregroundColor(.blue) + } + } + .padding(.horizontal) + } + } + } + .padding(.horizontal) + } + } + .navigationTitle("Problem Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + struct EditAttemptView: View { let attempt: Attempt @EnvironmentObject var dataManager: ClimbingDataManager @@ -556,3 +747,131 @@ struct EditAttemptView: View { ) .environmentObject(ClimbingDataManager.preview) } + +struct ProblemSelectionImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 80) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(height: 80) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemSelectionImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} diff --git a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift index 98e29af..d0f5f69 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift @@ -1,9 +1,3 @@ -// -// AddEditGymView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 17b9a38..3460c3d 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -1,9 +1,3 @@ -// -// AddEditProblemView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import PhotosUI import SwiftUI @@ -459,19 +453,10 @@ struct AddEditProblemView: View { private func loadSelectedPhotos() async { for item in selectedPhotos { if let data = try? await item.loadTransferable(type: Data.self) { - // Save to app's documents directory - let documentsPath = FileManager.default.urls( - for: .documentDirectory, in: .userDomainMask - ).first! - let imageName = "photo_\(UUID().uuidString).jpg" - let imagePath = documentsPath.appendingPathComponent(imageName) - - do { - try data.write(to: imagePath) - imagePaths.append(imagePath.path) + // Use ImageManager to save image + if let relativePath = ImageManager.shared.saveImageData(data) { + imagePaths.append(relativePath) imageData.append(data) - } catch { - print("Failed to save image: \(error)") } } } diff --git a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift index cd34e3a..724b482 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift @@ -1,9 +1,3 @@ -// -// AddEditSessionView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 59e031e..22837e3 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -1,10 +1,3 @@ -// -// AnalyticsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import SwiftUI struct AnalyticsView: View { @@ -538,8 +531,6 @@ struct ProgressDataPoint { let difficultySystem: DifficultySystem } -// MARK: - Helper Functions - #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/Detail/GymDetailView.swift b/ios/OpenClimb/Views/Detail/GymDetailView.swift index e1058a1..3b6e57f 100644 --- a/ios/OpenClimb/Views/Detail/GymDetailView.swift +++ b/ios/OpenClimb/Views/Detail/GymDetailView.swift @@ -1,9 +1,3 @@ -// -// GymDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index 32ac919..62dc277 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -1,9 +1,3 @@ -// -// ProblemDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -296,21 +290,11 @@ struct PhotosSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(imagePaths.indices, id: \.self) { index in - AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 12) - .fill(.gray.opacity(0.3)) - } - .frame(width: 120, height: 120) - .clipped() - .cornerRadius(12) - .onTapGesture { - selectedImageIndex = index - showingImageViewer = true - } + ProblemDetailImageView(imagePath: imagePaths[index]) + .onTapGesture { + selectedImageIndex = index + showingImageViewer = true + } } } .padding(.horizontal, 1) @@ -444,14 +428,8 @@ struct ImageViewerView: View { NavigationView { TabView(selection: $currentIndex) { ForEach(imagePaths.indices, id: \.self) { index in - AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in - image - .resizable() - .aspectRatio(contentMode: .fit) - } placeholder: { - ProgressView() - } - .tag(index) + ProblemDetailImageFullView(imagePath: imagePaths[index]) + .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .always)) @@ -468,6 +446,133 @@ struct ImageViewerView: View { } } +struct ProblemDetailImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 120, height: 120) + .clipped() + .cornerRadius(12) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(width: 120, height: 120) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title2) + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(width: 120, height: 120) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemDetailImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + Rectangle() + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + Rectangle() + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { NavigationView { ProblemDetailView(problemId: UUID()) diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index e71d3b9..8caee31 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -1,10 +1,5 @@ -// -// SessionDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// +import Combine import SwiftUI struct SessionDetailView: View { @@ -14,6 +9,8 @@ struct SessionDetailView: View { @State private var showingDeleteAlert = false @State private var showingAddAttempt = false @State private var editingAttempt: Attempt? + @State private var attemptToDelete: Attempt? + @State private var currentTime = Date() private var session: ClimbSession? { dataManager.session(withId: sessionId) @@ -39,15 +36,20 @@ struct SessionDetailView: View { calculateSessionStats() } + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var body: some View { ScrollView { LazyVStack(spacing: 20) { if let session = session, let gym = gym { - SessionHeaderCard(session: session, gym: gym, stats: sessionStats) + SessionHeaderCard( + session: session, gym: gym, stats: sessionStats, currentTime: currentTime) SessionStatsCard(stats: sessionStats) - AttemptsSection(attemptsWithProblems: attemptsWithProblems) + AttemptsSection( + attemptsWithProblems: attemptsWithProblems, + attemptToDelete: $attemptToDelete) } else { Text("Session not found") .foregroundColor(.secondary) @@ -55,6 +57,9 @@ struct SessionDetailView: View { } .padding() } + .onReceive(timer) { _ in + currentTime = Date() + } .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -80,6 +85,33 @@ struct SessionDetailView: View { } } } + .alert( + "Delete Attempt", + isPresented: Binding( + get: { attemptToDelete != nil }, + set: { if !$0 { attemptToDelete = nil } } + ) + ) { + Button("Cancel", role: .cancel) { + attemptToDelete = nil + } + Button("Delete", role: .destructive) { + if let attempt = attemptToDelete { + dataManager.deleteAttempt(attempt) + attemptToDelete = nil + } + } + } message: { + if let attempt = attemptToDelete, + let problem = dataManager.problem(withId: attempt.problemId) + { + Text( + "Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone." + ) + } else { + Text("Are you sure you want to delete this attempt? This action cannot be undone.") + } + } .overlay(alignment: .bottomTrailing) { if session?.status == .active { Button(action: { showingAddAttempt = true }) { @@ -140,12 +172,26 @@ struct SessionDetailView: View { private func gradeRange(for problems: [Problem]) -> String? { guard !problems.isEmpty else { return nil } - let grades = problems.map { $0.difficulty }.sorted() - if grades.count == 1 { - return grades.first?.grade - } else { - return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")" + let difficulties = problems.map { $0.difficulty } + + // Group by difficulty system first + let groupedBySystem = Dictionary(grouping: difficulties) { $0.system } + + // For each system, find the range + let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in + let sortedDifficulties = difficulties.sorted() + guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else { + return nil + } + + if min == max { + return min.grade + } else { + return "\(min.grade) - \(max.grade)" + } } + + return ranges.joined(separator: ", ") } } @@ -153,6 +199,7 @@ struct SessionHeaderCard: View { let session: ClimbSession let gym: Gym let stats: SessionStats + let currentTime: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -165,7 +212,13 @@ struct SessionHeaderCard: View { .font(.title2) .foregroundColor(.blue) - if let duration = session.duration { + if session.status == .active { + if let startTime = session.startTime { + Text("Duration: \(formatDuration(from: startTime, to: currentTime))") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else if let duration = session.duration { Text("Duration: \(duration) minutes") .font(.subheadline) .foregroundColor(.secondary) @@ -209,6 +262,21 @@ struct SessionHeaderCard: View { formatter.dateStyle = .full return formatter.string(from: date) } + + private func formatDuration(from start: Date, to end: Date) -> String { + let interval = end.timeIntervalSince(start) + let hours = Int(interval) / 3600 + let minutes = Int(interval) % 3600 / 60 + let seconds = Int(interval) % 60 + + if hours > 0 { + return String(format: "%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + return String(format: "%dm %ds", minutes, seconds) + } else { + return String(format: "%ds", seconds) + } + } } struct SessionStatsCard: View { @@ -276,6 +344,7 @@ struct StatItem: View { struct AttemptsSection: View { let attemptsWithProblems: [(Attempt, Problem)] + @Binding var attemptToDelete: Attempt? @EnvironmentObject var dataManager: ClimbingDataManager @State private var editingAttempt: Attempt? @@ -311,6 +380,30 @@ struct AttemptsSection: View { ForEach(attemptsWithProblems.indices, id: \.self) { index in let (attempt, problem) = attemptsWithProblems[index] AttemptCard(attempt: attempt, problem: problem) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(radius: 2) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + // Add haptic feedback for delete action + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + attemptToDelete = attempt + } label: { + Label("Delete", systemImage: "trash") + } + .accessibilityLabel("Delete attempt") + .accessibilityHint("Removes this attempt from the session") + + Button { + editingAttempt = attempt + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + .accessibilityLabel("Edit attempt") + .accessibilityHint("Modify the details of this attempt") + } .onTapGesture { editingAttempt = attempt } @@ -327,8 +420,6 @@ struct AttemptsSection: View { struct AttemptCard: View { let attempt: Attempt let problem: Problem - @EnvironmentObject var dataManager: ClimbingDataManager - @State private var showingDeleteAlert = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -353,15 +444,6 @@ struct AttemptCard: View { VStack(alignment: .trailing, spacing: 8) { AttemptResultBadge(result: attempt.result) - - HStack(spacing: 12) { - Button(action: { showingDeleteAlert = true }) { - Image(systemName: "trash") - .font(.caption) - .foregroundColor(.red) - } - .buttonStyle(.plain) - } } } @@ -378,19 +460,6 @@ struct AttemptCard: View { } } .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(.ultraThinMaterial) - .stroke(.quaternary, lineWidth: 1) - ) - .alert("Delete Attempt", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - dataManager.deleteAttempt(attempt) - } - } message: { - Text("Are you sure you want to delete this attempt?") - } } } diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift index f209f38..078dd4f 100644 --- a/ios/OpenClimb/Views/GymsView.swift +++ b/ios/OpenClimb/Views/GymsView.swift @@ -1,9 +1,3 @@ -// -// GymsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -37,14 +31,47 @@ struct GymsView: View { struct GymsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var gymToDelete: Gym? + @State private var gymToEdit: Gym? var body: some View { List(dataManager.gyms, id: \.id) { gym in NavigationLink(destination: GymDetailView(gymId: gym.id)) { GymRow(gym: gym) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + gymToDelete = gym + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + gymToEdit = gym + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { + Button("Cancel", role: .cancel) { + gymToDelete = nil + } + Button("Delete", role: .destructive) { + if let gym = gymToDelete { + dataManager.deleteGym(gym) + gymToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this gym? This will also delete all associated problems and sessions." + ) + } + .sheet(item: $gymToEdit) { gym in + AddEditGymView(gymId: gym.id) } - .listStyle(.plain) } } @@ -124,7 +151,7 @@ struct GymRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index a5a73d5..7014935 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -1,9 +1,3 @@ -// -// ProblemsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -45,9 +39,13 @@ struct ProblemsView: View { NavigationView { VStack(spacing: 0) { if !dataManager.problems.isEmpty { - FilterSection() - .padding() - .background(.regularMaterial) + FilterSection( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: filteredProblems + ) + .padding() + .background(.regularMaterial) } if filteredProblems.isEmpty { @@ -79,8 +77,9 @@ struct ProblemsView: View { struct FilterSection: View { @EnvironmentObject var dataManager: ClimbingDataManager - @State private var selectedClimbType: ClimbType? - @State private var selectedGym: Gym? + @Binding var selectedClimbType: ClimbType? + @Binding var selectedGym: Gym? + let filteredProblems: [Problem] var body: some View { VStack(spacing: 12) { @@ -154,19 +153,6 @@ struct FilterSection: View { } } - private var filteredProblems: [Problem] { - var filtered = dataManager.problems - - if let climbType = selectedClimbType { - filtered = filtered.filter { $0.climbType == climbType } - } - - if let gym = selectedGym { - filtered = filtered.filter { $0.gymId == gym.id } - } - - return filtered - } } struct FilterChip: View { @@ -195,14 +181,47 @@ struct FilterChip: View { struct ProblemsList: View { let problems: [Problem] @EnvironmentObject var dataManager: ClimbingDataManager + @State private var problemToDelete: Problem? + @State private var problemToEdit: Problem? var body: some View { List(problems) { problem in NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { ProblemRow(problem: problem) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + problemToDelete = problem + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + problemToEdit = problem + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { + Button("Cancel", role: .cancel) { + problemToDelete = nil + } + Button("Delete", role: .destructive) { + if let problem = problemToDelete { + dataManager.deleteProblem(problem) + problemToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this problem? This will also delete all associated attempts." + ) + } + .sheet(item: $problemToEdit) { problem in + AddEditProblemView(problemId: problem.id) } - .listStyle(.plain) } } @@ -269,19 +288,10 @@ struct ProblemRow: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in - AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 8) - .fill(.gray.opacity(0.3)) - } - .frame(width: 60, height: 60) - .clipped() - .cornerRadius(8) + ProblemImageView(imagePath: imagePath) } } + .padding(.horizontal, 4) } } @@ -292,7 +302,7 @@ struct ProblemRow: View { .fontWeight(.medium) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } @@ -356,6 +366,70 @@ struct EmptyProblemsView: View { } } +struct ProblemImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(width: 60, height: 60) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { ProblemsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index e2e5947..3e7f600 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -1,9 +1,3 @@ -// -// SessionsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Combine import SwiftUI @@ -127,6 +121,7 @@ struct ActiveSessionBanner: View { struct SessionsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var sessionToDelete: ClimbSession? private var completedSessions: [ClimbSession] { dataManager.sessions @@ -139,8 +134,29 @@ struct SessionsList: View { NavigationLink(destination: SessionDetailView(sessionId: session.id)) { SessionRow(session: session) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { + Button("Cancel", role: .cancel) { + sessionToDelete = nil + } + Button("Delete", role: .destructive) { + if let session = sessionToDelete { + dataManager.deleteSession(session) + sessionToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this session? This will also delete all attempts associated with this session." + ) } - .listStyle(.plain) } } @@ -179,7 +195,7 @@ struct SessionRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } private func formatDate(_ date: Date) -> String { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 018e9e3..38fbbe9 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -1,9 +1,3 @@ -// -// SettingsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI import UniformTypeIdentifiers @@ -23,6 +17,8 @@ struct SettingsView: View { activeSheet: $activeSheet ) + ImageStorageSection() + AppInfoSection() } .navigationTitle("Settings") @@ -130,6 +126,96 @@ struct DataManagementSection: View { } } +struct ImageStorageSection: View { + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingStorageInfo = false + @State private var storageInfo = "" + @State private var showingRecoveryAlert = false + @State private var showingEmergencyAlert = false + + var body: some View { + Section("Image Storage") { + // Storage Status + Button(action: { + storageInfo = dataManager.getImageRecoveryStatus() + showingStorageInfo = true + }) { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.blue) + Text("Check Storage Health") + Spacer() + } + } + .foregroundColor(.primary) + + // Manual Maintenance + Button(action: { + dataManager.manualImageMaintenance() + }) { + HStack { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.orange) + Text("Run Maintenance") + Spacer() + } + } + .foregroundColor(.primary) + + // Force Recovery + Button(action: { + showingRecoveryAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + .foregroundColor(.orange) + Text("Force Image Recovery") + Spacer() + } + } + .foregroundColor(.primary) + + // Emergency Restore + Button(action: { + showingEmergencyAlert = true + }) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Emergency Restore") + Spacer() + } + } + .foregroundColor(.red) + } + .alert("Storage Information", isPresented: $showingStorageInfo) { + Button("OK") {} + } message: { + Text(storageInfo) + } + .alert("Force Image Recovery", isPresented: $showingRecoveryAlert) { + Button("Cancel", role: .cancel) {} + Button("Force Recovery", role: .destructive) { + dataManager.forceImageRecovery() + } + } message: { + Text( + "This will attempt to recover missing images from backups and legacy locations. Use this if images are missing after app updates or debug sessions." + ) + } + .alert("Emergency Restore", isPresented: $showingEmergencyAlert) { + Button("Cancel", role: .cancel) {} + Button("Emergency Restore", role: .destructive) { + dataManager.emergencyImageRestore() + } + } message: { + Text( + "This will restore all images from the backup directory, potentially overwriting current images. Only use this if normal recovery fails." + ) + } + } +} + struct AppInfoSection: View { private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"