From 59bdad803fa4cb1fcdedc936e32eeabcfabb2d4d Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 17 May 2026 11:17:13 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Kisani=20UI=20overhaul=20=E2=80=94=20br?= =?UTF-8?q?and=20identity,=20gallery,=20visual=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename app to Kisani (CFBundleDisplayName, xcodeproj → Kisani.xcodeproj) - BackupView: kisani. wordmark + SwiftUI-drawn server logo mark (no asset, adaptive ink/bg), 206pt progress ring with active-state glow, 4-page swipeable ring (progress/pending/failed/uploaded), 4-metric stats strip, friendly NAS card (Home Server label), hamburger → AppMenuView sheet - GalleryView: new NAS + local photo grid with source picker chip, 3-col lazy grid, ThumbnailCache, sync-status dots (green = backed up) - MainTabView: Browse tab → Gallery tab; tab bar uses ultraThinMaterial blur background (systemGray3 icons, 9.5pt labels) - All app icon sizes regenerated from 1024pt source via sips - LANMonitor: nasReachable Bool?, checkNASReachability(host:port:) TCP ping - project.yml: photo library usage description updated to Kisani branding Co-Authored-By: Kutesir Co-Authored-By: Sentry --- App/Info.plist | 4 +- App/NASBackupApp.swift | 5 + .../AppIcon.appiconset/Icon-20@2x.png | Bin 1057 -> 1519 bytes .../AppIcon.appiconset/Icon-20@3x.png | Bin 1470 -> 1883 bytes .../AppIcon.appiconset/Icon-29@2x.png | Bin 1525 -> 1822 bytes .../AppIcon.appiconset/Icon-29@3x.png | Bin 1944 -> 2321 bytes .../AppIcon.appiconset/Icon-40@2x.png | Bin 1866 -> 2286 bytes .../AppIcon.appiconset/Icon-40@3x.png | Bin 2458 -> 2803 bytes .../AppIcon.appiconset/Icon-60@2x.png | Bin 2458 -> 2803 bytes .../AppIcon.appiconset/Icon-60@3x.png | Bin 3499 -> 3725 bytes .../KisaniLogo.imageset/Contents.json | 23 + .../KisaniLogo.imageset/KisaniLogo@1x.png | Bin 0 -> 2112 bytes .../KisaniLogo.imageset/KisaniLogo@2x.png | Bin 0 -> 3327 bytes .../KisaniLogo.imageset/KisaniLogo@3x.png | Bin 0 -> 4989 bytes Core/Protocols/NASTransferProtocol.swift | 1 + Features/Backup/BackupView.swift | 313 +++++++++---- Features/Browse/BrowseView.swift | 142 +++--- Features/Browse/GalleryView.swift | 429 ++++++++++++++++++ Features/Connect/ConnectView.swift | 2 +- Features/Connect/ConnectViewModel.swift | 30 +- Features/MainTabView.swift | 35 +- Features/Onboarding/FolderSetupView.swift | 2 +- Features/Sync/SyncView.swift | 31 +- .../project.pbxproj | 59 ++- .../contents.xcworkspacedata | 0 .../xcshareddata/swiftpm/Package.resolved | 0 Services/LANMonitor.swift | 4 + Services/SFTPService.swift | 8 + Services/SMBService.swift | 7 + Services/ThumbnailCache.swift | 50 ++ Shared/Components/FolderRow.swift | 61 +-- project.yml | 4 +- 32 files changed, 983 insertions(+), 227 deletions(-) create mode 100644 Assets.xcassets/KisaniLogo.imageset/Contents.json create mode 100644 Assets.xcassets/KisaniLogo.imageset/KisaniLogo@1x.png create mode 100644 Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png create mode 100644 Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png create mode 100644 Features/Browse/GalleryView.swift rename {NASBackup.xcodeproj => Kisani.xcodeproj}/project.pbxproj (93%) rename {NASBackup.xcodeproj => Kisani.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%) rename {NASBackup.xcodeproj => Kisani.xcodeproj}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (100%) create mode 100644 Services/ThumbnailCache.swift diff --git a/App/Info.plist b/App/Info.plist index d397705..e680de7 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -10,7 +10,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - NASBackup + Kisani CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -28,7 +28,7 @@ NSFaceIDUsageDescription Sign in quickly with Face ID. NSPhotoLibraryUsageDescription - NASBackup needs access to your photos to back them up to your NAS. + Kisani needs access to your photos to back them up to your NAS. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift index 95cb105..5812558 100644 --- a/App/NASBackupApp.swift +++ b/App/NASBackupApp.swift @@ -22,6 +22,7 @@ struct NASBackupApp: App { struct RootView: View { @EnvironmentObject var store: ConnectionStore + @EnvironmentObject var lanMonitor: LANMonitor var body: some View { Group { @@ -40,5 +41,9 @@ struct RootView: View { .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.isSessionActive) .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.savedConnection?.remotePath) .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.onboardingPhotoShown) + .task(id: store.isSessionActive) { + guard store.isSessionActive, let conn = store.savedConnection else { return } + await lanMonitor.checkNASReachability(host: conn.host, port: conn.port) + } } } diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png index d9940c924bcce80dd08166e23685a9c12708c125..548732907c4fe57ac2bdcf1ea9bd6989e1b82452 100644 GIT binary patch delta 1088 zcmZ3;@t%9be#Uyn`I#XRB@w}FfdWk9dNvV1jxdjX$U{hfQWag%pBq~(o=HwMy zRoE(lRagPpAYpwa1+bEmY+I!W-v9;Y{GwC^Gd%-610_2y1qB70qLehNAQv~Nb|BAI zDWjyMz)D}gyu4hm+*q$ZzbIYb(9+UU-@r)U$VeBcM7OvyuOzWTH?LS3VhYS4m(=3q zqRfJl%=|nBkim&b`K2YcN=hJ`-~i&#)QU`mRe8lwe*t}{50cS0)HBeBm|YLiXaf?q z3dpQT&53Zy%uUTJ23lfgXkcVw1J;2dg|HJzYb0*1NK#O(Hu@kx)FXKc5@cXuV3^r) z+33T=-;OJ^DLI0Hfhph9#W5s;^KIDfE+t2iy{9^ul`bULlbM>~RyH}Yq zC`@>K-0W?C=r{KnS^+P4R`RXl(<*h)j9cq?)p04G#?k#J6$^IX72s+;kYaSQv2)|@ zyL`Lu>a7XW{wVpZ>7R~=T-^Hb2pzFQ|F;}}taxbI<&_VLcB&kIJnI6V+v30itGN@- zrY*exo>@}RM5=cQql)2&3L66%K1CV6dgrZCskM9KrXTsl5Ms78sB>q+_S=CU?=+@u zK3Q$hAO=u(g6mnY5W zXIxQVr6Ja>Qs}rLYVC>&BaUwE>8B56mKnY4GNs72e5}yP-j&L2Bi9cM z=L<~1D@88Et_WLw@L@rM#XOxh7TpPxk3TMa@wG}KPt3V>>+M{#y&~Sy&y&nMgkMem z{`>Ets=aBaTjmJ=4OHmf$`PqIo$2Q6z+|DfOn2YbhubR&&p*#T(Szkonz7Qa;uB@N zQ}fQHZ9cERzrsSv`+>NOe(j%6tx+qhLKnPR5X3&^(N(Ds*^pNaL3^T_y=4EeZhX0U U`wY<=!k{w2)78&qol`;+00F|a0RR91 delta 622 zcmV-!0+Idi3!wihOMDdskw`?LlZZ%2M5k5jD3nB@ zpiue=5~5M~4Kxyoib5eFQEP;Vib8_;n0ID#cjM+2&X{|XTkIsedv@nJXP)!Snc4XO z079=n+cbZc*%vXDpy&*#3aScWA*q>L)8N-o@TR&!cr0nE}PAgN~I#b2ZKSW z2J`v6(tnSAWaEqBoz$sMsZ@e;xeR)}{;ddHQ_MDKm&<>}HBcs#0khc*Hk%FXb~~4^ z3`WT<&CL(pc&3<3LAJS8t5rEM)RQS^Fc@IHUW3o)gGQr)@{>Va1XrsS^m;wn7xkRY zX5jI7fb9Tu!$$esgU|$z$0Jy+)_Y%+-RX3I9WvM^&D3O##lkl9zj!ztLNpqMcDoJR z?G_tf!O>;|0>tBS@Or%v3WZ=gohp5=4Adg=`~74v7?8u^@YYhH$1Sp0EJ&eHK#fO9 zOE|QGud@8D{$JmyMTx5fRRu*A5cOAUP*qS=0a1Uo21OP82Tk|uMUn%n{Qv*}07*qo IM6N<$g0@vHAOHXW diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png index 50bf9416f497eb381f0d8299a1c9ad43fbf9a58c..35cb7767b6f7eb022ea0ea6f981aadc20d7a37af 100644 GIT binary patch delta 1455 zcmdnTeVcE?e#Uyn`I#XRB@w}FfdWk9dNvV1jxdjX$U{hfQWag%pBq~(o=HwMy zRoE(lRagPpAYpwa1+bEmY+I!W-v9;Y{GwC^Gd%-610_2y1qB70qLehNAQv~Nb|BAI zDWjyMz)D}gyu4hm+*q$ZzbIYb(9+UU-@r)U$VeBcM7OvyuOzWTH?LS3VhYS4m(=3q zqRfJl%=|nBkim&b`K2YcN=hJ`-~i&#)QU`mRe8lwe*t}{50cS0)HBeBm|YLiXaf?q z3dpQT&53Zy%uUTJ23lfgXkcVw1J;2dg|HJzYb0*1NK#O(Hu@kx)FXKc5@cXuV3^r) z+33T=-;OJ^DLI0Hf%&$li(^Oz>)QxRZyiU0b+OllMI2p3SOb}v19`4E2powKQT}*> zhn4rp{)7~Tv?hh7ok5C+DJiZ0 z8d@KF*-$Cy*AG*%|E3=QnZ7o}rr$AsBUz@yWVcjS*@U?--9lNV&-Me4wOa<8%=rR0 z51Hx*l4U&?cr5n!J#=y7;E{Xs{CRM2u<+FB(}Q)yj6ZxRoqYbh|E?W77Ni(?=I7_f z@BbnHKcV~hkBsc>;N81-zk2-GIko=uz5DmCUb{9eD>KtjF1sJQs$bLaR> z&CH5wz8>IcpOPwT^G&?}xaLHU4~^`~`SV}?`ZcSot1F|XX3nu=$5w3JI@RR#mc4s(n@sN>v+!+eZ@28u z&#%u9b#-msC~rHdpf$1wX^c7>w&Tu{0v&ZI=>ipi1DRf>b>|{6I^xD;M-!xCAZB?Of+oEuhySB)pcp*ne(Mf zmwsqudj0;r|9$>*?q=>4EF>+tjE&+piVR23CXGH3X(Qp4~~>_g`#A3sifuzKIV zdFBjEQ~7}LWLG+Fm|)1bIbvGE%8;&Gw{O3E_|Wl6 zs|U*$^*NWA6CF=Jd->r*Kxs)y$FWt5m$>k-C%Ogp|5e+2HaS<1eP3Wlf-!@1MNrzq z%Ull<3hW<-FS%sTm{8IqS74&F?rwEkY_dt$abua@j_mLA60b}2KKjVxuKeiM-gNaF qrf!QXYz|-S*irUgvaI)_#yoq?qII)YMj3tu6~UgaelF{r5}E*tKyc6i delta 1039 zcmV+q1n~RY4!#SpzX5*(=}AOERA>e5Sh-8}KoI}E=Lu>fq88qbM<;@KG%A9Hq82KO zrGkQmrIoF=wpN0OAQme4FDQan;Z>^Oi5H^c?S4D!_a5&hLS8l-32_Hq9n}u|9dV7YM%ZT&&{NuHfSTOV2%AFUvj=|~VV?nNaz`U<3W?7i zXoP(RsL7o_hj6?p1U4TO7Z;RTFLH2jfSsKkq@|@{XlMwnt*v-^dO~(~HXa`z?JJIuke(**@9&IgbaYf& z2e%_ic5G~n?eFii`T2R4n3yQu_4f9P@6d?{B#PfCZ#92*G^OL?zs0Yvt|H>+?6#_^ z3Z#Hi8OnPV`$MQW(9!gq`jg2CJ3kwT^zrMcSTMj=wJmB*3 z($bk&7`nQ;KnEqQsi`UPtEen!Jh^%J`uY;pTPy*x%cEpUQ$k5kPq+K+ANglyW^jIf zj@{i|k*G*Fm6xhWiNnp!&B)8kQ)QFTQ(j&UI*5Pr^Yg`3#YxGs^!D}^E)rB2Wz-QD z7Z+aVJ+UlNmR?(1GukONCg*5Fa&~qGTHlGN@aXI7lhXH-kaRIl#XQmA;Gnb)ZcvnL zcXv1AOB@>+8DW7yK)h>eYWn^VKNl7jM)4cvt;S0JVQ$z@fs}eSFff3+x;k86Ut?lo zLV14$=g48&kkgJyRfeVrt*)+$of1`Uv^)_->JM!w#0!a&BZqnGMPyJu<*>QAsiXn0 zv9Te7NDTnW($oOx?Ccb-Q3HTB7EZ`fmZm93I~r>7P(7`p<6(4$9ImXaL{d@`mX?;# z-rjCxu|1Yf0%c`oY;JCjot&I7zBGjsi^_i+>Z)8^Trj>8u-e*MXa z(Syp$$`Vu2cDOABBHOey-JOzShn*Vm+wz{=Jh&MI?Xa8C^@47VuoqBp(*}*Oo6+@x zZjG=PP;k=*jj)^1^@47VuoqBp(*}*Oo6+@xZjG=PP;k?Rf3}FfdWk9dNvV1jxdjX$U{hfQWag%pBq~(o=HwMy zRoE(lRagPpAYpwa1+bEmY+I!W-v9;Y{GwC^Gd%-610_2y1qB70qLehNAQv~Nb|BAI zDWjyMz)D}gyu4hm+*q$ZzbIYb(9+UU-@r)U$VeBcM7OvyuOzWTH?LS3VhYS4m(=3q zqRfJl%=|nBkim&b`K2YcN=hJ`-~i&#)QU`mRe8lwe*t}{50cS0)HBeBm|YLiXaf?q z3dpQT&53Zy%uUTJ23lfgXkcVw1J;2dg|HJzYb0*1NK#O(Hu@kx)FXKc5@cXuV3^r) z+33T=-;OJ^DLI0Hfq9;%i(^Oz>)QxxZ*51Be?sxC2}_l_)D)T)n1v}tblk{L?r;@h z>QdV0pd*qU?HX``f5WCtpYBFxD3&QIv@PguhF>-T2wG2UQ3 zd+r(Y=hpXU&NP;0PMJV)AO8RLt!>gIp(|IfCT3-ErKYB? zva_^Id?;P%beO_ie7gS>C$3x;m4t{QU4ipwoqE`}XabduGj&GF!)H&{ey0Do5s& zS@!=Qm6esLrM!^3snyA)-4z!b`|#PbHq-VKucs)ScfPICI{-V~-vw@$vB)7#S_vv32X#Ld#YE!op(b z-YM4Ya$BtB*x{nIV$GUAxeuNnZ0yeU`SSQNb4lRJRjVG|TKG#}M<*dapTEAixLB*k zcw?qc)EuAg!WWlh7F}7tbEl-Ru<(R<#w&%r#ZwkFe?NANE#s@Kj7*67%!dyXkG}o% z>C%+sUGY|TC*>Day?gmm@RG*VMU|C{&zsJjJJ<5&H*;~TlR^!JFJHe7o^Sc;_3Kch zbDZmalr@Yz9;O&|Zry77=H0tAv*&-#sAaBl*U{Atye5Sh-3pQ4l@jzTt+Tf~dF#?jV}EAz~niiGi_^ zk%9ODCjNn-e_&>=CT@s`;0ms|ueji@sQ2}`I57BL!+5LTW5(A7jbnG;sycP*R#jgQ z0A*c2UP696!cTxM(4WS%%c#p}kVe2w-5@2-)@_$j+t38*1DAi%ASKS$ZI@Bo&;;oN zm(d_4&erWP+b4cL2n`KITwENYqoWZL;#`6#rSSaxjJvx#yuH2o>5cts#y|-wKR+L} zwY4ZHD9~V?2KxB;h~3>?OiWB*d3jk3m`5q!zH)MMRDXZJIy^j7A0HnU0e?p>E-uv2 z(2y!CEBm&OzPNvk8Lg_SLR(uK5)u+HJw2@>{`&fApF4g$@n(b-IE00T=}f7ttVC;T zE0&g)u(Gm(_xE={H2waHG0?26EM#V8Vr^{=gM)(@9UX-PXkua_YHDgUxS5$5EG#Ub zyu2JeJw2$euLte14&2}0|Bk|sJH|kxqN23ny}dmwE-ruK?Ci`BbM~)TTR~??Lqh|? z!^1HZEb<~A|oU9WeU`|OnN*X?KLZ+X*&7BW;R@1UE%on7}wX=VER(Gx8l;C z*QW0}e}5Y@>NUVU%IfRgU*7xQ{mZl@i>IfjAd49p8AwV>LUMBQcUArIeKF9Yq9Sy4 zb>a5*_K$xCOl~r86s9ZnJ0W7A92T;(vvqTK!XaNBth~$^CqxXCRd;xJ7*;j?~mtR99D{si{eWW&3hc#Ed%S3}@Prkr97vZ*OCKd|VIn@$vCash6j2US1wL zIy!K2aw2w$d-C$~QnxF%7Z$qW;$pF0DiAZu^3NjT#L(~+5qooNZ0y(DM*oX}?(gqo zZf;IbHAWuzEMny)I8H;>w-91tV!)4wl9Cdix6FQufv&EuVqjnZ>+9=g9(=Ci6vUaf zu&{p+larHrN@6Pa_Vxw_P|m0~H#d53Nsw}=Z)s^kV`HOcb82cz&%8Wka6*jR3x{r| z7ss@ShzPMSPft&}pzZAJ;Qahtr!VzVKZUkv(^QO8_u=6|e;aZxWy*1Hy}Z1b`k?`F zs6RS70tW^bxQdDjU3FRLXivhfG0=^T4efsr3q@yVC+h0zL_mEbM4g;-36^{I;NSqX zN7f}`bQY7Ip02vPyVd;syt2MrlofSH4~?ueRThiNIzCZ-eSOLr5smz`0;AV?K7SYm zDlIKd&!XN_K*@*kR|mK!$si{twlAJ!NJIowV)$RLHLkk<+EJ-J7pOFXfwtadG|)a6 zk^18@Dve;Et#=s>G)AQUxQt387-;KVMgxrzsXs2G(g+6H`u~Q}Uz!nQan*|*5&!@I M07*qoM6N<$f|=bG6aWAK diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png index 8132dc3e8c16f4800b1ddd901fb836de2a35d1c9..346881a2f6ce4a29f583e76db0bff1f7a3e5dd21 100644 GIT binary patch delta 1896 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 HDI)D3lKRxh diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png index 57ab1789e28fb32b03f0af8f8ce3922e5e9bd827..3e88b82096eed35084cc70c7733b5591927301ec 100644 GIT binary patch delta 1861 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 diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png index 1b8ff20df90434797f4af5108297c8c012ce10c6..b08c004eaf30bb14748373475521a382012d7c4f 100644 GIT binary patch delta 2382 zcmb_edpOhkAGdZwS}idVV^k#Bg?{UYO~)-dqE3_BDK@rTW;QczbNiyBP8gC!baky< zT8boNj+k{-J6LFho-TVW{pmC$iA?vSAUQOK!gAp_mPM%*=F&Vj9j3Wsx|hq2ZgpxctY+o)YfQ3}Uc?=%I*ZW0D^|f`vCQSWf!0 zzC4t|3HmE4H2mvaOB14&5flcAMt$)Hz6f#4ZOi>Z97>3%eGrWj8om@_jX|63zdVSLx0iNit%5`SxqoYwg9-kg3JOZ$d+cr8IG{J@ z4m-hY)WvCM&Rz}L5@>_E>}l;}op_1pNefqQA88Nx`ilw`|JMrjI4WC*L2&#*Vd-?L^ zl~)~6MzIv}Pz3;diVYi=<0lvA**5r?#kqqlHaoet7MFivE;}bNs#gTUn48<3FUbQ~ zEVr>2cRLhY1{I163r`mBySQzzIzvB~dMY!MtX}byIx*QVzAO-+Coda%LM+gqhO zfN;dtc11x!fvA~xjZdLa;*x6@G*6da^L8|DYiqmz)EbCgP|ia69+%5~vM*lrn904L zz6chbUK=0Uc}p_yU|N0t?AcY~RQR#g8<5`z2M1^0yLN3`e%y~Krwr{cO!hs$89*w# zF~&>E$=SFm((ePJ;pEg5^G9#*ZRBz5{ryXDV_ySr{{6Vp8D2!~r|KR^@4I&_3eiSf8y%OVo;hg8f2PIl zd(J=ld-o~_2ZyAs&)trFS3J;lh6E#aJ+#5gp5_9%xr72q6kDXay-N+8YBMA)-p808 zX%iga%(r)ULu5Cr@LEEbiWle7>r6b#MFRty&*8Z1H8eCX<>!ORjwZLdMtn;Yx(;os zsi{%O3S334qAek{_>*9+ZJP0jmT^-v1=znPc40OQIFve#!Z;E8=B7vMO9rl2hQEGn?EQgsj8;6O zHzbir;t9pB##=^5Mw}kb-MZ=GHnt;6^|#Bm|H5LMY>=2#=VyoMj#pu+Sf`YYLGT^2e+znnW5WVxawSP=aJ9NJSy z>r^XFeR6(#G2>bTn%JgR%q$Jg+XZ}tFm*9r$0j>d%+WjP@(>q@J4jbcD?TG*J@w&J zSOU?vE_zke%-GXKX*K_~;T!-h!-w_#yz~4Op%Bh+-BDNo$#I?xdy+pT015vcKDbxb zc)Y2}L0<{4o;FJY7fL^7xpNI7>1*Z+{Rt`b^#?T51`Rv+A&GEMzrtSfuO8kB@>QwZ z10r)lVniHN`@$}uJTn7TP-a2Cs*cSjLC~pIj;VNmmp!ho*uWE!KwxxrYin!oqXFeN za81p)qN1YK!4cG2Lf;q^P)U;31Ne6S;{;q__ghBn>Dof&(c0+w8B@XA3f)LQIM^!D zF-;dXbs9pgTN^p|Zdkx!fP1#2PS^L}>5;{tt@+&P5tzEVddqCA;R3R~lG@R`@ZoK1 zPtUkeNxd^*ZUZnqGd<0Pn}kpp9}jM&y{Lhu>R9v%(>3m|UhESJC1eO%G3NDSxML)4a=!pl0~eWOfkrKN zm*N1hq2gW-B)B~y#gnVJ7m{6mNU?NO^Tre;TN8cw4#cwq^@+I`C2Uq)YTVlc=Hxo<{ delta 2034 zcmcJPdpOe#8^?{BLz5mN=r_gvPId73eBO;zRYFZg%&Sj3jC7NszYDx|{MD{Q% zC5Mtj4modg7?wjzJTb8tGWNROzu)Wqq1Q>7#mNr~4T&xB6p}3MSAoAw^qT+XW4R(UunT_!`cY#q9|0lW~(; zW}0JnPshdm46FOZ_Fu&!VgO%o^)|=^IVitkX*qxdFExl361tT7@K)X}KUYrQQKHRD z;ndX7C&l=_puu(yB%q~GelBf$TY2=Jd(-a(Fj>Fw@* zW*+rzh~o8^j*U&xao2puJK{3seSPPTvbRfR{QUgZ{$^^&?ff$R+z>?=D6lx^U30~& zYj?B#aN)T#kBa^+Pk}FMRa$b`7`m?TJg=rQF@+fu#SO&LF%chVPDK_Tk327cCfNdQUb=1 z)$T=l(;78D@O#?O2S7OOyS=+(kto>AYEIaK85<;7gfVY7CvG#I+1-&4(q>;FTSZ4I zaI0VJH$9y{9BsHC!mf9>j9UYAmg`2Rrl=DfHrrRDb|USnM=W(iJD(}LbxxObR(F;Qw*|7aGM}?#-yL-K^kx}N9-&_qWeYF&8ggBA+2tkynCPH2+}+gU7Ufq`qeW*!i_iY_dYVSeeSW?g)7l9@=Ri@KI{Q z*K^X@&JI-V*F<(8DHi*>xVcq4()Xr7<8k?gI$%}`*F&ik+Hx=X^-H*EK%XK^T7jJ& z6&X2PeF~?tO8`(Otza~%@Czp{TbFVnb#GS=xYdN8za@o@s6A(+7@o-xB!1z|^+wOO z?Kiznd@?#V#@zZjFUh5H=e)H7n=0-K;9zx2+ykm#lY`=znVDI3!eOVuLMxN6SyMNZ z!e!&$1HwWIh2n{5W9!KUi=feHW*v5NnB(%-VMaI@;P~~WjC)prNCZ)yB$;9+`L)rA z{VB2lnIeRohW%sA?Q#^c^Ret;$>R6#`6JQ%rSaEs>zF*t*jJ`dA2CH#&*itF^va7B zRljL3NEABGLVuT1i$12;OE%DI$ZpqbNg%qO)r*F3rh9svvwPDWT_YIfh^NCU;oW^b zHIUPQ=mCY}ORSf0#7X2ON2-RFd6&!LSWV{UbJYr^@O|PU4$=zet)IQ`s2}6E;k6`E z(FD!DYKwln)&q7^Oy=Gz%=PAe_rZ~M15v%`;5d4nge;%4yR(f^%JQ9XaUq{@&w&tR z)XSL_ZUjWNU;v-f63OY5)-yD8ZDUx=0l^}iKi5b~CV_u5p7SdomYw_gt-n+Qe)m{V zfwD=k2+AyI&kCjd_?ocGt3GGW91UJFM+S$|SyTp_eIAR&4mewf&)6!PN0!lV4*9R{ zZSswq_(7Em#GZE7MxJVp^fsScx_7e6oL8p!YWHKewcvz<&)eEy|9ppoz6avgow2;6 z%@u!9m+gbl8HwZV1euBgix|pOV#pvpU0y#>xVw`wS6g>v`MUif*r?&vX2JYj^~NOcA7oS@ zo|a!q*q6!KD6#<#syT0=x!wwnGVwc4v$JJaH&;=y;@e(d1ps9)U*tKR_ZQcvg{KnE z^TWFRAF|%X*5Y_yKh+?=YCqv#`!V$%RyW@H1xjvZNecHSt(Q+*s1KQL_A|i5Z!8yh z8Obf`a%tX*@XE8Qh9MW=`Hx-9yhxzt_zmjBOM<_|St*5gX02`(qV?$~0ZD^PK5k^;MA6|dHNjn=IKm#reDNf(pO9EpfninKK{=NmI>(SsgX zY_)0awM!xyLl4|GU?3DW+$iB&5k{v4Z~8Hk4m<=kIU?H8;3^F=u^hs($0%EyAf;dW z{&Azl(1W@11QeV$SE4I+D=Y8rAjAiL;s`>CMjZYlA)rP1T0>wD-mk2D)|M`NVaViv0bVTq A8~^|S diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png index 1b8ff20df90434797f4af5108297c8c012ce10c6..b08c004eaf30bb14748373475521a382012d7c4f 100644 GIT binary patch delta 2382 zcmb_edpOhkAGdZwS}idVV^k#Bg?{UYO~)-dqE3_BDK@rTW;QczbNiyBP8gC!baky< zT8boNj+k{-J6LFho-TVW{pmC$iA?vSAUQOK!gAp_mPM%*=F&Vj9j3Wsx|hq2ZgpxctY+o)YfQ3}Uc?=%I*ZW0D^|f`vCQSWf!0 zzC4t|3HmE4H2mvaOB14&5flcAMt$)Hz6f#4ZOi>Z97>3%eGrWj8om@_jX|63zdVSLx0iNit%5`SxqoYwg9-kg3JOZ$d+cr8IG{J@ z4m-hY)WvCM&Rz}L5@>_E>}l;}op_1pNefqQA88Nx`ilw`|JMrjI4WC*L2&#*Vd-?L^ zl~)~6MzIv}Pz3;diVYi=<0lvA**5r?#kqqlHaoet7MFivE;}bNs#gTUn48<3FUbQ~ zEVr>2cRLhY1{I163r`mBySQzzIzvB~dMY!MtX}byIx*QVzAO-+Coda%LM+gqhO zfN;dtc11x!fvA~xjZdLa;*x6@G*6da^L8|DYiqmz)EbCgP|ia69+%5~vM*lrn904L zz6chbUK=0Uc}p_yU|N0t?AcY~RQR#g8<5`z2M1^0yLN3`e%y~Krwr{cO!hs$89*w# zF~&>E$=SFm((ePJ;pEg5^G9#*ZRBz5{ryXDV_ySr{{6Vp8D2!~r|KR^@4I&_3eiSf8y%OVo;hg8f2PIl zd(J=ld-o~_2ZyAs&)trFS3J;lh6E#aJ+#5gp5_9%xr72q6kDXay-N+8YBMA)-p808 zX%iga%(r)ULu5Cr@LEEbiWle7>r6b#MFRty&*8Z1H8eCX<>!ORjwZLdMtn;Yx(;os zsi{%O3S334qAek{_>*9+ZJP0jmT^-v1=znPc40OQIFve#!Z;E8=B7vMO9rl2hQEGn?EQgsj8;6O zHzbir;t9pB##=^5Mw}kb-MZ=GHnt;6^|#Bm|H5LMY>=2#=VyoMj#pu+Sf`YYLGT^2e+znnW5WVxawSP=aJ9NJSy z>r^XFeR6(#G2>bTn%JgR%q$Jg+XZ}tFm*9r$0j>d%+WjP@(>q@J4jbcD?TG*J@w&J zSOU?vE_zke%-GXKX*K_~;T!-h!-w_#yz~4Op%Bh+-BDNo$#I?xdy+pT015vcKDbxb zc)Y2}L0<{4o;FJY7fL^7xpNI7>1*Z+{Rt`b^#?T51`Rv+A&GEMzrtSfuO8kB@>QwZ z10r)lVniHN`@$}uJTn7TP-a2Cs*cSjLC~pIj;VNmmp!ho*uWE!KwxxrYin!oqXFeN za81p)qN1YK!4cG2Lf;q^P)U;31Ne6S;{;q__ghBn>Dof&(c0+w8B@XA3f)LQIM^!D zF-;dXbs9pgTN^p|Zdkx!fP1#2PS^L}>5;{tt@+&P5tzEVddqCA;R3R~lG@R`@ZoK1 zPtUkeNxd^*ZUZnqGd<0Pn}kpp9}jM&y{Lhu>R9v%(>3m|UhESJC1eO%G3NDSxML)4a=!pl0~eWOfkrKN zm*N1hq2gW-B)B~y#gnVJ7m{6mNU?NO^Tre;TN8cw4#cwq^@+I`C2Uq)YTVlc=Hxo<{ delta 2034 zcmcJPdpOe#8^?{BLz5mN=r_gvPId73eBO;zRYFZg%&Sj3jC7NszYDx|{MD{Q% zC5Mtj4modg7?wjzJTb8tGWNROzu)Wqq1Q>7#mNr~4T&xB6p}3MSAoAw^qT+XW4R(UunT_!`cY#q9|0lW~(; zW}0JnPshdm46FOZ_Fu&!VgO%o^)|=^IVitkX*qxdFExl361tT7@K)X}KUYrQQKHRD z;ndX7C&l=_puu(yB%q~GelBf$TY2=Jd(-a(Fj>Fw@* zW*+rzh~o8^j*U&xao2puJK{3seSPPTvbRfR{QUgZ{$^^&?ff$R+z>?=D6lx^U30~& zYj?B#aN)T#kBa^+Pk}FMRa$b`7`m?TJg=rQF@+fu#SO&LF%chVPDK_Tk327cCfNdQUb=1 z)$T=l(;78D@O#?O2S7OOyS=+(kto>AYEIaK85<;7gfVY7CvG#I+1-&4(q>;FTSZ4I zaI0VJH$9y{9BsHC!mf9>j9UYAmg`2Rrl=DfHrrRDb|USnM=W(iJD(}LbxxObR(F;Qw*|7aGM}?#-yL-K^kx}N9-&_qWeYF&8ggBA+2tkynCPH2+}+gU7Ufq`qeW*!i_iY_dYVSeeSW?g)7l9@=Ri@KI{Q z*K^X@&JI-V*F<(8DHi*>xVcq4()Xr7<8k?gI$%}`*F&ik+Hx=X^-H*EK%XK^T7jJ& z6&X2PeF~?tO8`(Otza~%@Czp{TbFVnb#GS=xYdN8za@o@s6A(+7@o-xB!1z|^+wOO z?Kiznd@?#V#@zZjFUh5H=e)H7n=0-K;9zx2+ykm#lY`=znVDI3!eOVuLMxN6SyMNZ z!e!&$1HwWIh2n{5W9!KUi=feHW*v5NnB(%-VMaI@;P~~WjC)prNCZ)yB$;9+`L)rA z{VB2lnIeRohW%sA?Q#^c^Ret;$>R6#`6JQ%rSaEs>zF*t*jJ`dA2CH#&*itF^va7B zRljL3NEABGLVuT1i$12;OE%DI$ZpqbNg%qO)r*F3rh9svvwPDWT_YIfh^NCU;oW^b zHIUPQ=mCY}ORSf0#7X2ON2-RFd6&!LSWV{UbJYr^@O|PU4$=zet)IQ`s2}6E;k6`E z(FD!DYKwln)&q7^Oy=Gz%=PAe_rZ~M15v%`;5d4nge;%4yR(f^%JQ9XaUq{@&w&tR z)XSL_ZUjWNU;v-f63OY5)-yD8ZDUx=0l^}iKi5b~CV_u5p7SdomYw_gt-n+Qe)m{V zfwD=k2+AyI&kCjd_?ocGt3GGW91UJFM+S$|SyTp_eIAR&4mewf&)6!PN0!lV4*9R{ zZSswq_(7Em#GZE7MxJVp^fsScx_7e6oL8p!YWHKewcvz<&)eEy|9ppoz6avgow2;6 z%@u!9m+gbl8HwZV1euBgix|pOV#pvpU0y#>xVw`wS6g>v`MUif*r?&vX2JYj^~NOcA7oS@ zo|a!q*q6!KD6#<#syT0=x!wwnGVwc4v$JJaH&;=y;@e(d1ps9)U*tKR_ZQcvg{KnE z^TWFRAF|%X*5Y_yKh+?=YCqv#`!V$%RyW@H1xjvZNecHSt(Q+*s1KQL_A|i5Z!8yh z8Obf`a%tX*@XE8Qh9MW=`Hx-9yhxzt_zmjBOM<_|St*5gX02`(qV?$~0ZD^PK5k^;MA6|dHNjn=IKm#reDNf(pO9EpfninKK{=NmI>(SsgX zY_)0awM!xyLl4|GU?3DW+$iB&5k{v4Z~8Hk4m<=kIU?H8;3^F=u^hs($0%EyAf;dW z{&Azl(1W@11QeV$SE4I+D=Y8rAjAiL;s`>CMjZYlA)rP1T0>wD-mk2D)|M`NVaViv0bVTq A8~^|S diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png index a5e12d51aed3b805a2505a3d48f58901134f8a5d..94bb47d14796c1599c4c5158383578d9abfa2144 100644 GIT binary patch literal 3725 zcmeHKXHb*t5)MU5kP;EW&?2IEKnX-Z3B4Mmh!p7{B|wmb5=tNff*N`gr4y-Qp-6`a z7|N8kp}xt=p~@BMNApKoTrZ@=ArcHez=W@p|=LxU^qKrj#h0I=&I zG>vHA%0ppcqP>+qGQj`-As@oqY2yRvg~ zCOEpC?A^Mb9|Osx@|X{=yMZa|Q-K;oU^iYj6r0t~kt&%rPGA@z!W9#1eHAm~R=~6V zdP1>&OdbklW}+tN}B`&Z7hp1Do&E^8~4oFU!rPP6!!f3NhuhPF;8 zn+h--8x&X;FENdtI(;`o*-&cmMt=O&*u?xh?_uCor$}jydnj1Q{2K*@-=3fId-*9B z!D>~_oqO)X
wU$u5h}eHIJ70rxH&$5 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 OijJ0nW{LXEuzvxm3$)z; literal 3499 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 diff --git a/Assets.xcassets/KisaniLogo.imageset/Contents.json b/Assets.xcassets/KisaniLogo.imageset/Contents.json new file mode 100644 index 0000000..a4fa913 --- /dev/null +++ b/Assets.xcassets/KisaniLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "KisaniLogo@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "KisaniLogo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "KisaniLogo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@1x.png b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..375a73befef799e57e9b5de99d3a69a27321a623 GIT binary patch literal 2112 zcmb_ddo)|=7Ee(1ew98Lm5DJW$Vohc2vgOx6~=hZntBv%O;K8p zskW-BW=4aM(azN&9vvx)mTH(`jCzjMa!=acwrkyW|GDd)wf6ab-`@MT_qTuRTj%5x zeBD%)bd?|wh^mLXi$Az}OHDx@d_TD0X9|Hpy=izn!2^$n5fWGwS{xYyF)mIkLVG!r zv~vu+G@S>lYigdU4aJb?LycORRu^?bCf4b?^Xs#z_;FqO0Xj`NY$5-4H!`k*;G47kGzinI~Pt3}`I&*+L+OaOu+i0cldl3=YJT#Kv#_j~r z%cGBaX?zavk7rGO!?)x&b)sO-kY^2Y84N z0fuKKkYN}k3W+j^R6kYd9^P9XF!~JI3dZpRUo&EOIRLSYfU*1X- zeJM%2W|_47Yo+7~XJGY`rKp1XIRxUFUi-5VKwe@2=pC6EqkVqtSHhXZ);n-jCdzE$6)zy!DLoA(~nu?GN%A_wWAa>3b7en3L z+z9^uQKgg9)558#!#Er+qkzAqx@8rM{cC%hp6c)K?_F75u4ry?G2=&K@pX~txaF|!c{+oUR3A1?DQ#+OL<9y09~hk;8@pCjRrRg9ZYcr) z)bH(-9gaHLYUr}33a_uPKh^qpa&mopH+VW_S5Hrm$YQZ5859boCvL%b^HnUJu5@ps zav-G1u3z|S#J8}eO+?J>hN-BiI2mhO5IefM49=C5I7Ev?qKJv*67eB7p$V$Xph6x9Qa9?AM}g7ilNi3s2iKq2GVm&<<*v7 z9D@s*q4u`68J(Sum%&SHpg%oJa}} z4|fUpxyxCJ`-gOugx#iuxq%#m@Xp(@P!Keq5WOKZDzP@S~rWHpX%6qf? zgxlNL3E|^TT~aBiN_M5*+S-~EkLRcyfQh71sVHmf-kH|G@ySWX%jUaxt${khDF%Z9 zD!S=0G&2LAUzHWAL7!D!OsoH!sHQ9qd~G_SBsV-Y^(R^&H`?`8Z1-syHN7|i>gH3# z)~hcfA~0b%1uB``Yp-$iQbn6z*ByuD`}bX!HCzm85ouAg-kZU^nNQt)e0(mdIyjW^ z3V-{gY#5kpymAG;xE-8ooaS3N*3#1Q>oty_U-1U?>Bh!>jExPna01RTGSql*z%}SO zT5nvu7hP0Y$*`L3A%=x1H=Vp+S&$KNsDFMZ3+Onzv^KS7Te9>;uLnw%pu{{;fcdLaM+ literal 0 HcmV?d00001 diff --git a/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b3f7d9f14f48c7fd8285b45ff412337a504a98a5 GIT binary patch literal 3327 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 literal 0 HcmV?d00001 diff --git a/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..fc7aeaf8315165d4ba09fb88b90fd5cd85834186 GIT binary patch literal 4989 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 Void ) async throws + func downloadData(at remotePath: String) async throws -> Data } diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index a727f25..97e86c5 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -8,92 +8,141 @@ struct BackupView: View { @StateObject private var vm = BackupViewModel() @State private var showMenu = false + @State private var showDestinationPicker = false + @State private var pickerPath: String = "/" @State private var ringPage = 0 + @State private var nasFileCount: Int? = nil private let ringPageCount = 4 var body: some View { - ZStack { + ZStack(alignment: .topTrailing) { AppTheme.background.ignoresSafeArea() + // ─── Menu — aligned with kisani. wordmark ────────────────── + Button { showMenu = true } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Activity menu") + .padding(.top, 4) + .padding(.trailing, AppTheme.hPad - 4) + VStack(spacing: 0) { - Spacer(minLength: 0) - // ─── Ring hero ─────────────────────────────────────── + // ─── Brand ─────────────────────────────────────────── + VStack(spacing: 10) { + Text("kisani.") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(AppTheme.ink) + .kerning(2) + kisaniLogoMark + } + .frame(maxWidth: .infinity) + .padding(.top, 16) + + // flex zone — brand ↔ ring (absorbs screen-size variance) + Spacer(minLength: 24).frame(maxHeight: 48) + + // ─── Ring ───────────────────────────────────────────── ringHero - .padding(.bottom, 24) + .padding(.bottom, 44) - // ─── Compact stats strip (always visible) ───────────── + // ─── Stats ──────────────────────────────────────────── statsStrip .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 12) .transition(.opacity.combined(with: .scale(scale: 0.97))) - Spacer(minLength: 0) + // flex zone — stats ↔ cards + Spacer(minLength: 20).frame(maxHeight: 48) - // ─── NAS status ─────────────────────────────────────── + // ─── Storage card ───────────────────────────────────── nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) + // ─── Source card ────────────────────────────────────── + destinationRow + .padding(.horizontal, AppTheme.hPad) + + // flex zone — cards ↔ CTA + Spacer(minLength: 20).frame(maxHeight: 32) + // ─── Photos access nudge ────────────────────────────── if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { photosAccessRow .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 12) + .padding(.bottom, 8) .transition(.opacity.combined(with: .move(edge: .bottom))) } - // ─── Action ─────────────────────────────────────────── + // ─── CTA ────────────────────────────────────────────── actionButton .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 32) + .padding(.bottom, 24) + + // bottom absorber — soaks up remaining height to anchor cards+CTA + Spacer(minLength: 0) } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - logoChip - } - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showMenu = true - } label: { - Image(systemName: "line.3.horizontal") - .font(.system(size: 16, weight: .regular)) - .foregroundStyle(AppTheme.inkSecondary) - } - .accessibilityLabel("Activity menu") - } - } + .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showMenu) { AppMenuView() .environmentObject(store) .environmentObject(lanMonitor) } + .sheet(isPresented: $showDestinationPicker, onDismiss: { + guard pickerPath != "/", var conn = store.savedConnection else { return } + conn.remotePath = pickerPath + store.savedConnection = conn + }) { + if let conn = store.savedConnection { + BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath) + } + } .animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) - .task { vm.loadPhotoCount(filter: store.backupFilter) } + .task { + vm.loadPhotoCount(filter: store.backupFilter) + await loadNASCount() + } } - // MARK: — Logo chip + // MARK: — Logo mark (drawn in code — no asset dependency) - private var logoChip: some View { - HStack(spacing: 6) { - ZStack { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(AppTheme.ink) - .frame(width: 22, height: 22) - Image(systemName: "server.rack") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(AppTheme.inkInverse) - } - Text("kisani.") - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .foregroundStyle(AppTheme.inkSecondary) - .kerning(0.5) + private var kisaniLogoMark: some View { + VStack(spacing: 0) { + driveRow + AppTheme.ink.frame(height: 1.5) + driveRow + AppTheme.ink.frame(height: 1.5) + driveRow } + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(AppTheme.ink, lineWidth: 2.5) + ) + .frame(width: 66, height: 66) + .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2) + } + + private var driveRow: some View { + HStack(spacing: 0) { + Spacer() + Circle() + .fill(AppTheme.ink) + .frame(width: 7, height: 7) + .padding(.trailing, 7) + } + .frame(maxWidth: .infinity) + .frame(height: 18) + .background(AppTheme.surfaceRaised) } // MARK: — Ring hero @@ -102,11 +151,16 @@ struct BackupView: View { ZStack { ProgressRing( progress: engine.job.progress, - lineWidth: 7, - size: 220, + lineWidth: 8, + size: 206, color: ringColor, - backgroundColor: AppTheme.inkQuaternary.opacity(0.4) + backgroundColor: AppTheme.inkQuaternary.opacity(0.6) ) + .shadow( + color: ringColor.opacity(engine.job.status.isActive ? 0.16 : 0), + radius: 22, x: 0, y: 0 + ) + .animation(.easeInOut(duration: 0.5), value: engine.job.status.isActive) .accessibilityLabel("Backup progress") .accessibilityValue("\(Int(engine.job.progress * 100)) percent") @@ -194,12 +248,6 @@ struct BackupView: View { .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) } - Text(engine.job.currentFileName) - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(AppTheme.inkTertiary) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 160) } case .preparing: Text("Preparing…") @@ -243,7 +291,7 @@ struct BackupView: View { case .failed: return AppTheme.destructive case .running, .paused, .preparing: return Color(red: 1.0, green: 0.58, blue: 0.0) - default: return AppTheme.inkQuaternary.opacity(0.6) + default: return AppTheme.inkTertiary.opacity(0.35) } } @@ -252,43 +300,139 @@ struct BackupView: View { // MARK: — Compact stats strip private var statsStrip: some View { - VStack(spacing: 5) { + VStack(spacing: 8) { HStack(spacing: 0) { - compactStat("\(engine.job.uploadedFiles)", label: "up", color: AppTheme.positive) + compactStat( + nasFileCount.map { "\($0)" } ?? "–", + label: "in NAS", + color: AppTheme.interactive + ) thinDivider - compactStat("\(engine.job.skippedFiles)", label: "skip", color: AppTheme.inkQuaternary) + compactStat( + "\(vm.totalPhotoCount)", + label: "on iPhone", + color: AppTheme.inkSecondary + ) thinDivider - compactStat("\(engine.job.failedFiles)", label: "err", - color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary) + compactStat( + "\(engine.job.uploadedFiles)", + label: "uploaded", + color: AppTheme.positive + ) thinDivider - compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", color: AppTheme.inkSecondary) + compactStat( + "\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", + label: "left", + color: AppTheme.inkSecondary + ) } + .frame(height: 56) .background(AppTheme.surfaceSunken) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) pageIndicator } } private func compactStat(_ value: String, label: String, color: Color) -> some View { - HStack(spacing: 4) { + VStack(spacing: 3) { Text(value) - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(color) .monospacedDigit() Text(label) - .font(.system(size: 10, weight: .regular)) + .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.inkQuaternary) } .frame(maxWidth: .infinity) - .padding(.vertical, 6) + .padding(.vertical, 8) } private var thinDivider: some View { Rectangle() - .fill(AppTheme.separator) - .frame(width: 0.5) - .padding(.vertical, 5) + .fill(AppTheme.separator.opacity(0.7)) + .frame(width: 0.5, height: 22) + } + + private func loadNASCount() async { + guard let conn = store.savedConnection else { return } + do { + let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + try await s.connect(to: conn.host, port: conn.port, + username: conn.username, password: conn.password) + let items = try await s.listDirectory(at: conn.remotePath) + nasFileCount = items.filter { !$0.isDirectory }.count + s.disconnect() + } catch { + nasFileCount = nil + } + } + + // MARK: — Destination row + + private var destinationRow: some View { + Button(action: { + pickerPath = store.savedConnection?.remotePath ?? "/" + showDestinationPicker = true + }) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(AppTheme.surfaceSunken) + .frame(width: 34, height: 34) + Image(systemName: "folder.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + } + + let path = store.savedConnection?.remotePath ?? "/" + let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent + + VStack(alignment: .leading, spacing: 1) { + Text(folderName) + .font(AppTheme.headline()) + .foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink) + Text("Backup folder") + .font(AppTheme.micro(10)) + .foregroundStyle(AppTheme.inkTertiary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(AppTheme.surfaceSunken) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + } + .buttonStyle(ScaleButtonStyle()) + .disabled(store.savedConnection == nil) + } + + private var nasStatusDotColor: Color { + switch engine.job.status { + case .running, .preparing, .paused: + return Color(red: 1.0, green: 0.58, blue: 0.0) + default: + switch lanMonitor.nasReachable { + case true: return AppTheme.positive + case false: return AppTheme.destructive + case nil: return AppTheme.inkQuaternary + } + } + } + + private var nasStatusLabel: String { + switch engine.job.status { + case .running: return "Backing up" + case .preparing: return "Preparing" + case .paused: return "Paused" + default: + return lanMonitor.nasReachable == false ? "Offline" : "Ready" + } } // MARK: — NAS status row @@ -305,24 +449,17 @@ struct BackupView: View { } if let conn = store.savedConnection { + let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName VStack(alignment: .leading, spacing: 1) { - Text(conn.displayName) + Text(friendlyName) .font(AppTheme.headline()) .foregroundStyle(AppTheme.ink) - HStack(spacing: 4) { - Text(conn.nasProtocol.rawValue) - .foregroundStyle(AppTheme.inkTertiary) - if let ssid = lanMonitor.currentSSID { - Text("·") - .foregroundStyle(AppTheme.inkQuaternary) - Text(ssid) - .foregroundStyle(AppTheme.inkTertiary) - } - } - .font(AppTheme.micro(10)) + Text("\(conn.nasProtocol.rawValue) · \(conn.host)") + .font(AppTheme.micro(10)) + .foregroundStyle(AppTheme.inkTertiary) } } else { - Text("No NAS configured") + Text("No storage connected") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkTertiary) } @@ -332,16 +469,22 @@ struct BackupView: View { VStack(alignment: .trailing, spacing: 2) { HStack(spacing: 4) { Circle() - .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary) + .fill(nasStatusDotColor) .frame(width: 6, height: 6) - Text(engine.job.status.isActive ? "Active" : "Ready") + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive) + Text(nasStatusLabel) .font(AppTheme.micro()) - .foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary) + .foregroundStyle(nasStatusDotColor) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel) } - if let reachable = lanMonitor.nasReachable { - Text(reachable ? "NAS reachable" : "NAS offline") + if lanMonitor.nasReachable == false { + Text("Unreachable") .font(.system(size: 9, weight: .regular)) - .foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.opacity(0.7)) + .foregroundStyle(AppTheme.destructive.opacity(0.7)) + } else if lanMonitor.nasReachable == true && !engine.job.status.isActive { + Text("Connected") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(AppTheme.positive.opacity(0.7)) } } } diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift index 1acd34d..1d9ed86 100644 --- a/Features/Browse/BrowseView.swift +++ b/Features/Browse/BrowseView.swift @@ -3,79 +3,102 @@ import SwiftUI struct BrowseView: View { let connection: NASConnection @Binding var selectedPath: String + var isPickerMode: Bool = false @Environment(\.dismiss) private var dismiss - @State private var currentPath: String = "/" + @State private var currentPath: String @State private var items: [NASItem] = [] @State private var isLoading = false @State private var error: String? = nil @State private var showCreateFolder = false @State private var newFolderName = "" + init(connection: NASConnection, selectedPath: Binding, isPickerMode: Bool = false, initialPath: String = "/") { + self.connection = connection + self._selectedPath = selectedPath + self.isPickerMode = isPickerMode + self._currentPath = State(initialValue: initialPath) + } + var body: some View { - NavigationStack { - ZStack { - AppTheme.background.ignoresSafeArea() + if isPickerMode { + NavigationStack { coreContent } + } else { + coreContent + } + } - VStack(spacing: 0) { - // Path breadcrumb bar - HStack(spacing: 6) { - Image(systemName: "externaldrive") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(AppTheme.inkTertiary) - Text(currentPath) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(AppTheme.inkSecondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - } - .padding(.horizontal, AppTheme.cardPad) - .padding(.vertical, 10) - .background(AppTheme.surfaceSunken) + private var coreContent: some View { + ZStack { + AppTheme.background.ignoresSafeArea() - Rectangle() - .fill(AppTheme.separator) - .frame(height: 0.5) - - if isLoading { - loadingState - } else if let err = error { - errorState(err) - } else if items.isEmpty { - emptyState - } else { - folderList + VStack(spacing: 0) { + // Path breadcrumb bar + HStack(spacing: 6) { + Image(systemName: "externaldrive") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AppTheme.inkTertiary) + Text(currentPath) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if !isPickerMode && currentPath != "/" { + Button(action: navigateUp) { + Image(systemName: "arrow.up.circle") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + } } } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 10) + .background(AppTheme.surfaceSunken) + + Rectangle() + .fill(AppTheme.separator) + .frame(height: 0.5) + + if isLoading { + loadingState + } else if let err = error { + errorState(err) + } else if items.isEmpty { + emptyState + } else { + folderList + } } - .navigationTitle("Choose folder") - .navigationBarTitleDisplayMode(.inline) - .toolbar { + } + .navigationTitle(isPickerMode ? "Choose folder" : "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isPickerMode { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } .font(AppTheme.body()) .foregroundStyle(AppTheme.inkSecondary) } - ToolbarItem(placement: .navigationBarTrailing) { - Button { - newFolderName = "" - showCreateFolder = true - } label: { - Image(systemName: "folder.badge.plus") - .foregroundStyle(AppTheme.inkSecondary) - } - .disabled(currentPath == "/" && connection.nasProtocol == .smb) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + newFolderName = "" + showCreateFolder = true + } label: { + Image(systemName: "folder.badge.plus") + .foregroundStyle(AppTheme.inkSecondary) } + .disabled(currentPath == "/" && connection.nasProtocol == .smb) } - .safeAreaInset(edge: .bottom) { - selectButton - } - .alert("New folder", isPresented: $showCreateFolder) { - TextField("Folder name", text: $newFolderName) - Button("Create") { Task { await createFolder() } } - Button("Cancel", role: .cancel) {} - } + } + .safeAreaInset(edge: .bottom) { + if isPickerMode { selectButton } + } + .alert("New folder", isPresented: $showCreateFolder) { + TextField("Folder name", text: $newFolderName) + Button("Create") { Task { await createFolder() } } + Button("Cancel", role: .cancel) {} } .task { await loadItems(path: currentPath) } .refreshable { await loadItems(path: currentPath) } @@ -183,14 +206,10 @@ struct BrowseView: View { ForEach(items) { item in FolderRow( item: item, - isSelected: selectedPath == item.path - ) { - if item.isDirectory { - navigate(to: item.path) - } else { - selectedPath = item.path - } - } + isSelected: selectedPath == item.path, + onTap: { selectedPath = item.path }, + onNavigate: item.isDirectory ? { navigate(to: item.path) } : nil + ) .contextMenu { if item.isDirectory { Button { @@ -252,7 +271,7 @@ struct BrowseView: View { Spacer() Button(action: { - selectedPath = currentPath + if selectedPath == "/" { selectedPath = currentPath } dismiss() }) { Text("Select") @@ -300,6 +319,7 @@ struct BrowseView: View { } private func navigate(to path: String) { + if isPickerMode { selectedPath = "/" } currentPath = path Task { await loadItems(path: path) } } diff --git a/Features/Browse/GalleryView.swift b/Features/Browse/GalleryView.swift new file mode 100644 index 0000000..5574c6e --- /dev/null +++ b/Features/Browse/GalleryView.swift @@ -0,0 +1,429 @@ +import SwiftUI +import Photos +import UIKit + +private let imageExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"] +private let videoExts: Set = ["mp4","mov","m4v","avi","mkv"] + +enum GallerySource { case nas, local } + +struct GalleryView: View { + @EnvironmentObject var store: ConnectionStore + @EnvironmentObject var lanMonitor: LANMonitor + + @State private var source: GallerySource = .nas + + // NAS state + @State private var nasItems: [NASItem] = [] + @State private var nasLoading = false + @State private var nasError: String? = nil + + // Local state + @State private var localAssets: [PHAsset] = [] + @State private var syncedNames: Set = [] + @State private var localAuthDenied = false + + @State private var appeared = false + + private var connection: NASConnection? { store.savedConnection } + private let deviceName = UIDevice.current.name + + private let columns = [ + GridItem(.flexible(), spacing: 2), + GridItem(.flexible(), spacing: 2), + GridItem(.flexible(), spacing: 2) + ] + + var body: some View { + ZStack { + AppTheme.background.ignoresSafeArea() + + switch source { + case .nas: nasContent + case .local: localContent + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { sourceChip } + } + .task { + await loadNAS() + loadLocal() + } + .refreshable { + await loadNAS() + loadLocal() + } + } + + // MARK: — Source chip / dropdown + + private var sourceChip: some View { + Menu { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas } + } label: { + Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill") + } + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local } + } label: { + Label(deviceName, systemImage: "iphone") + } + } label: { + HStack(spacing: 5) { + Circle() + .fill(dotColor) + .frame(width: 6, height: 6) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dotColor == AppTheme.positive) + Text(source == .nas ? (connection?.host ?? "NAS") : deviceName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AppTheme.inkQuaternary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(AppTheme.surfaceSunken) + .clipShape(Capsule(style: .continuous)) + } + } + + private var dotColor: Color { + switch source { + case .nas: + switch lanMonitor.nasReachable { + case true: return AppTheme.positive + case false: return AppTheme.destructive + case nil: return AppTheme.inkQuaternary + } + case .local: + return AppTheme.interactive + } + } + + // MARK: — NAS content + + private var nasContent: some View { + Group { + if connection == nil { + placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected") + } else if nasLoading && nasItems.isEmpty { + loadingView("Loading gallery…") + } else if let err = nasError { + errorView(err) { Task { await loadNAS() } } + } else if nasItems.isEmpty { + placeholder(icon: "photo.on.rectangle", text: "No photos backed up yet") + } else { + gallery(items: nasItems.map { .nas($0) }) + } + } + } + + // MARK: — Local content + + private var localContent: some View { + Group { + if localAuthDenied { + placeholder(icon: "lock.slash", text: "Photo library access denied") + } else if localAssets.isEmpty { + loadingView("Loading photos…") + } else { + gallery(items: localAssets.map { .local($0) }) + } + } + } + + // MARK: — Shared grid + + private func gallery(items: [GalleryItem]) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { idx, item in + GalleryCell( + item: item, + connection: connection, + isSynced: syncStatus(for: item) + ) + .opacity(appeared ? 1 : 0) + .animation( + .spring(response: 0.35, dampingFraction: 0.82) + .delay(Double(min(idx, 18)) * 0.02), + value: appeared + ) + } + } + } header: { + gridHeader(count: items.count) + } + } + } + .onAppear { appeared = true } + } + + private func gridHeader(count: Int) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("\(count) files") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.ink) + Group { + switch source { + case .nas: + if let conn = connection { + Text((conn.remotePath as NSString).lastPathComponent) + } + case .local: + Text(deviceName) + } + } + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + if nasLoading { + ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary) + } + } + .padding(.horizontal, AppTheme.hPad) + .padding(.vertical, 10) + .background(AppTheme.background) + } + + // MARK: — Sync status + + private func syncStatus(for item: GalleryItem) -> SyncStatus { + guard source == .local else { return .none } + switch item { + case .local(let asset): + let name = PHAssetResource.assetResources(for: asset) + .first?.originalFilename.lowercased() ?? "" + return syncedNames.contains(name) ? .synced : .pending + case .nas: + return .none + } + } + + // MARK: — Data + + private func loadNAS() async { + guard let conn = connection else { return } + nasLoading = true + nasError = nil + do { + let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + try await s.connect(to: conn.host, port: conn.port, + username: conn.username, password: conn.password) + nasItems = try await s.listDirectory(at: conn.remotePath) + .filter { !$0.isDirectory } + .sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) } + s.disconnect() + syncedNames = Set(nasItems.map { $0.name.lowercased() }) + } catch let e as BackupError { + nasError = e.errorDescription + } catch { + nasError = error.localizedDescription + } + nasLoading = false + } + + private func loadLocal() { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard status == .authorized || status == .limited else { + if status == .denied || status == .restricted { localAuthDenied = true } + PHPhotoLibrary.requestAuthorization(for: .readWrite) { granted in + Task { @MainActor in + if granted == .authorized || granted == .limited { + loadLocalAssets() + } else { + localAuthDenied = true + } + } + } + return + } + loadLocalAssets() + } + + private func loadLocalAssets() { + let opts = PHFetchOptions() + opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let result = PHAsset.fetchAssets(with: opts) + var assets: [PHAsset] = [] + result.enumerateObjects { asset, _, _ in assets.append(asset) } + localAssets = assets + } + + // MARK: — Utility views + + private func loadingView(_ text: String) -> some View { + VStack(spacing: 10) { + ProgressView().tint(AppTheme.inkTertiary) + Text(text).font(AppTheme.caption()).foregroundStyle(AppTheme.inkTertiary) + } + } + + private func placeholder(icon: String, text: String) -> some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 32, weight: .ultraLight)) + .foregroundStyle(AppTheme.inkQuaternary) + Text(text) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + + private func errorView(_ message: String, retry: @escaping () -> Void) -> some View { + VStack(spacing: 16) { + placeholder(icon: "wifi.exclamationmark", text: message) + Button(action: retry) { + Text("Retry") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.ink) + .padding(.horizontal, 16).padding(.vertical, 8) + .background(AppTheme.surfaceSunken) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(ScaleButtonStyle()) + } + } +} + +// MARK: — Gallery item model + +enum GalleryItem { + case nas(NASItem) + case local(PHAsset) +} + +enum SyncStatus { case none, synced, pending } + +// MARK: — Cell + +struct GalleryCell: View { + let item: GalleryItem + let connection: NASConnection? + let isSynced: SyncStatus + + @State private var image: UIImage? = nil + @State private var loading = false + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if let img = image { + Image(uiImage: img) + .resizable() + .scaledToFill() + .transition(.opacity.animation(.easeIn(duration: 0.18))) + } else { + Rectangle() + .fill(AppTheme.surfaceSunken) + .overlay { + if loading { + ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary) + } else { + Image(systemName: cellIcon) + .font(.system(size: 18, weight: .ultraLight)) + .foregroundStyle(AppTheme.inkQuaternary) + } + } + } + } + + // Video badge + if isVideo && image != nil { + Image(systemName: "play.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .padding(3) + .background(.black.opacity(0.45)) + .clipShape(Circle()) + .padding(5) + } + + // Sync status badge (local mode only) + if isSynced != .none { + Circle() + .fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0)) + .frame(width: 9, height: 9) + .overlay(Circle().stroke(Color.white, lineWidth: 1.5)) + .padding(5) + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSynced == .synced) + } + } + .aspectRatio(1, contentMode: .fit) + .clipped() + .task { await load() } + } + + private var isVideo: Bool { + switch item { + case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased()) + case .local(let a): return a.mediaType == .video + } + } + + private var cellIcon: String { + switch item { + case .nas(let n): + let ext = (n.name as NSString).pathExtension.lowercased() + return videoExts.contains(ext) ? "play.rectangle" : "photo" + case .local(let a): + return a.mediaType == .video ? "play.rectangle" : "photo" + } + } + + private func load() async { + guard image == nil, !loading else { return } + + switch item { + case .nas(let nasItem): + guard let conn = connection else { return } + let ext = (nasItem.name as NSString).pathExtension.lowercased() + guard imageExts.contains(ext) || videoExts.contains(ext) else { return } + if let cached = ThumbnailCache.shared.get(for: nasItem.path) { image = cached; return } + loading = true + do { + let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + try await s.connect(to: conn.host, port: conn.port, + username: conn.username, password: conn.password) + let data = try await s.downloadData(at: nasItem.path) + s.disconnect() + if let img = UIImage(data: data)?.thumbnailScaled(to: 300) { + ThumbnailCache.shared.set(img, for: nasItem.path) + image = img + } + } catch {} + loading = false + + case .local(let asset): + loading = true + let opts = PHImageRequestOptions() + opts.deliveryMode = .opportunistic + opts.isNetworkAccessAllowed = true + opts.isSynchronous = false + let size = CGSize(width: 300, height: 300) + await withCheckedContinuation { cont in + PHImageManager.default().requestImage( + for: asset, targetSize: size, + contentMode: .aspectFill, options: opts + ) { img, info in + if let img { image = img } + let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false + if !degraded { cont.resume() } + } + } + loading = false + } + } +} diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift index 6735139..28ab5d5 100644 --- a/Features/Connect/ConnectView.swift +++ b/Features/Connect/ConnectView.swift @@ -268,7 +268,7 @@ struct ConnectView: View { .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .sheet(isPresented: $vm.showFolderBrowser) { if let conn = ConnectionStore.shared.savedConnection { - BrowseView(connection: conn, selectedPath: $vm.remotePath) + BrowseView(connection: conn, selectedPath: $vm.remotePath, isPickerMode: true) } } } diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift index 7f1f0fa..dea73c2 100644 --- a/Features/Connect/ConnectViewModel.swift +++ b/Features/Connect/ConnectViewModel.swift @@ -5,16 +5,34 @@ enum FieldState { case idle, valid, invalid } @MainActor final class ConnectViewModel: ObservableObject { - @Published var host = "" - @Published var username = "" - @Published var password = "" - @Published var remotePath = "/" - @Published var selectedProtocol: NASProtocol = .smb + @Published var host: String + @Published var username: String + @Published var password: String + @Published var remotePath: String + @Published var selectedProtocol: NASProtocol @Published var isVerifying = false - @Published var isConnected = false + @Published var isConnected: Bool @Published var verifyError: String? = nil @Published var showFolderBrowser = false + init() { + if let conn = ConnectionStore.shared.savedConnection { + host = conn.host + username = conn.username + password = conn.password + remotePath = conn.remotePath + selectedProtocol = conn.nasProtocol + isConnected = true + } else { + host = "" + username = "" + password = "" + remotePath = "/" + selectedProtocol = .smb + isConnected = false + } + } + var fieldState: FieldState { isConnected ? .valid : (verifyError != nil ? .invalid : .idle) } diff --git a/Features/MainTabView.swift b/Features/MainTabView.swift index 13a8842..687b5cb 100644 --- a/Features/MainTabView.swift +++ b/Features/MainTabView.swift @@ -27,20 +27,34 @@ struct iPhoneTabLayout: View { NavigationStack { BackupView() } .tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") } + NavigationStack { GalleryView() } + .tabItem { Label("Gallery", systemImage: "photo.on.rectangle") } + NavigationStack { SyncView() } .tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") } - NavigationStack { - if let conn = ConnectionStore.shared.savedConnection { - BrowseView(connection: conn, selectedPath: .constant("/")) - } - } - .tabItem { Label("Browse", systemImage: "folder.fill") } - NavigationStack { SettingsView() } .tabItem { Label("Settings", systemImage: "gearshape.fill") } } .tint(AppTheme.ink) + .onAppear { + let appearance = UITabBarAppearance() + // Native iOS blur — feels lighter than opaque white, more premium + appearance.configureWithDefaultBackground() + appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterial) + appearance.shadowColor = UIColor.separator.withAlphaComponent(0.4) + + let itemAppearance = UITabBarItemAppearance() + itemAppearance.normal.iconColor = UIColor.systemGray3 + itemAppearance.normal.titleTextAttributes = [ + .font: UIFont.systemFont(ofSize: 9.5, weight: .regular), + .foregroundColor: UIColor.systemGray3 + ] + appearance.stackedLayoutAppearance = itemAppearance + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + } } } @@ -51,8 +65,8 @@ struct iPadSidebarLayout: View { NavigationSplitView { List(selection: $selection) { Label("Backup", systemImage: "arrow.up.doc.fill").tag("backup") + Label("Gallery", systemImage: "photo.on.rectangle").tag("browse") Label("Sync", systemImage: "arrow.triangle.2.circlepath").tag("sync") - Label("Browse", systemImage: "folder.fill").tag("browse") Label("Settings", systemImage: "gearshape.fill").tag("settings") } .navigationTitle("Kisani") @@ -60,10 +74,7 @@ struct iPadSidebarLayout: View { switch selection { case "backup": BackupView() case "sync": SyncView() - case "browse": - if let conn = ConnectionStore.shared.savedConnection { - BrowseView(connection: conn, selectedPath: .constant("/")) - } + case "browse": GalleryView() case "settings": SettingsView() default: BackupView() } diff --git a/Features/Onboarding/FolderSetupView.swift b/Features/Onboarding/FolderSetupView.swift index 4ff1489..4ba3367 100644 --- a/Features/Onboarding/FolderSetupView.swift +++ b/Features/Onboarding/FolderSetupView.swift @@ -107,7 +107,7 @@ struct FolderSetupView: View { } .sheet(isPresented: $showBrowser) { if let conn = connection { - BrowseView(connection: conn, selectedPath: $selectedPath) + BrowseView(connection: conn, selectedPath: $selectedPath, isPickerMode: true) } } .onAppear { appeared = true } diff --git a/Features/Sync/SyncView.swift b/Features/Sync/SyncView.swift index 1c06a6c..8be8f02 100644 --- a/Features/Sync/SyncView.swift +++ b/Features/Sync/SyncView.swift @@ -174,11 +174,13 @@ struct SyncView: View { Spacer() HStack(spacing: 5) { Circle() - .fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary) + .fill(reachableColor) .frame(width: 6, height: 6) - Text(lanMonitor.nasReachable == true ? "Available" : "Offline") + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable) + Text(reachableLabel) .font(AppTheme.micro()) - .foregroundStyle(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkTertiary) + .foregroundStyle(reachableColor) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable) } } } @@ -274,6 +276,22 @@ struct SyncView: View { } } + private var reachableColor: Color { + switch lanMonitor.nasReachable { + case true: return AppTheme.positive + case false: return AppTheme.destructive.opacity(0.7) + case nil: return AppTheme.inkQuaternary + } + } + + private var reachableLabel: String { + switch lanMonitor.nasReachable { + case true: return "Online" + case false: return "Offline" + case nil: return "Checking…" + } + } + // MARK: — Toolbar chip private var nasStatusChip: some View { @@ -281,14 +299,11 @@ struct SyncView: View { Circle() .fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary) .frame(width: 5, height: 5) - Text(connection?.displayName ?? "NAS") + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable) + Text(connection?.host ?? "NAS") .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(AppTheme.surfaceSunken) - .clipShape(Capsule(style: .continuous)) } // MARK: — Data diff --git a/NASBackup.xcodeproj/project.pbxproj b/Kisani.xcodeproj/project.pbxproj similarity index 93% rename from NASBackup.xcodeproj/project.pbxproj rename to Kisani.xcodeproj/project.pbxproj index 2e3e970..407227d 100644 --- a/NASBackup.xcodeproj/project.pbxproj +++ b/Kisani.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */; }; 8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */; }; 9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03F0ED1A914F3392BFE5E4A /* BackupError.swift */; }; + 950B9DB26C77C1E63BCC96F2 /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2C125977BE8DD28FBF7F70 /* ThumbnailCache.swift */; }; 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */; }; 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; }; 9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; }; @@ -45,6 +46,7 @@ BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B67584BB6D705BCB3D40192 /* SFTPService.swift */; }; BEBDBED4A7D1D786C47DB8EE /* SyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A1E7D60A4D778026CC8007F /* SyncView.swift */; }; C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; }; + C7DC18C08F2839D8EE6AE0C3 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F928753E2C260E3CF57259DD /* GalleryView.swift */; }; C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; }; CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; }; CE84BC18F045AB65CCA30BB4 /* LANMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */; }; @@ -71,7 +73,7 @@ /* Begin PBXFileReference section */ 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = ""; }; - 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = ""; }; 2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = ""; }; @@ -98,6 +100,7 @@ 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = ""; }; 8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = ""; }; 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 8D2C125977BE8DD28FBF7F70 /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = ""; }; 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; @@ -105,14 +108,14 @@ B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; }; B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = ""; }; BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = ""; }; - C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C755BFDD42D46A3FB490A4BB /* Kisani.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kisani.app; sourceTree = BUILT_PRODUCTS_DIR; }; C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = ""; }; CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; }; D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; }; D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = ""; }; - E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = NASBackupTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E3A1E9181F350DA9F091D019 /* KisaniTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KisaniTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = ""; }; F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; @@ -121,6 +124,7 @@ F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; + F928753E2C260E3CF57259DD /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -257,8 +261,8 @@ 8308FE572D56B8045F17F6A0 /* Products */ = { isa = PBXGroup; children = ( - C755BFDD42D46A3FB490A4BB /* NASBackup.app */, - E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */, + C755BFDD42D46A3FB490A4BB /* Kisani.app */, + E3A1E9181F350DA9F091D019 /* KisaniTests.xctest */, ); name = Products; sourceTree = ""; @@ -275,6 +279,7 @@ isa = PBXGroup; children = ( D947C8F8945A2AB081BBD0EF /* BrowseView.swift */, + F928753E2C260E3CF57259DD /* GalleryView.swift */, ); path = Browse; sourceTree = ""; @@ -300,6 +305,7 @@ 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */, 4B67584BB6D705BCB3D40192 /* SFTPService.swift */, DEBD92D10D70B957192E271F /* SMBService.swift */, + 8D2C125977BE8DD28FBF7F70 /* ThumbnailCache.swift */, ); path = Services; sourceTree = ""; @@ -372,9 +378,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 32316B985B8906FE4CB97730 /* NASBackup */ = { + 32316B985B8906FE4CB97730 /* Kisani */ = { isa = PBXNativeTarget; - buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */; + buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "Kisani" */; buildPhases = ( E6D3CCCB60977A7DABFE7F19 /* Sources */, 6DB4B780F7FFCAAC2B48E78F /* Resources */, @@ -384,18 +390,18 @@ ); dependencies = ( ); - name = NASBackup; + name = Kisani; packageProductDependencies = ( 9CEDCE7A5B4B267C66F39ABE /* SMBClient */, 7BEB5BEBEB935BC8E2182A7B /* Citadel */, ); productName = NASBackup; - productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */; + productReference = C755BFDD42D46A3FB490A4BB /* Kisani.app */; productType = "com.apple.product-type.application"; }; - 88E89D71F1D8FA18A6A64BBF /* NASBackupTests */ = { + 88E89D71F1D8FA18A6A64BBF /* KisaniTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */; + buildConfigurationList = 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "KisaniTests" */; buildPhases = ( E2CED6E8ED76A743BB9C913F /* Sources */, ); @@ -404,11 +410,11 @@ dependencies = ( EA438E47054B868AA6F6727D /* PBXTargetDependency */, ); - name = NASBackupTests; + name = KisaniTests; packageProductDependencies = ( ); productName = NASBackupTests; - productReference = E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */; + productReference = E3A1E9181F350DA9F091D019 /* KisaniTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -421,7 +427,6 @@ LastUpgradeCheck = 1500; TargetAttributes = { 32316B985B8906FE4CB97730 = { - DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; 88E89D71F1D8FA18A6A64BBF = { @@ -430,7 +435,7 @@ }; }; }; - buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */; + buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "Kisani" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -448,8 +453,8 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 32316B985B8906FE4CB97730 /* NASBackup */, - 88E89D71F1D8FA18A6A64BBF /* NASBackupTests */, + 32316B985B8906FE4CB97730 /* Kisani */, + 88E89D71F1D8FA18A6A64BBF /* KisaniTests */, ); }; /* End PBXProject section */ @@ -500,6 +505,7 @@ 30693CB8EE711577978EF88C /* Date+.swift in Sources */, 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */, 77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */, + C7DC18C08F2839D8EE6AE0C3 /* GalleryView.swift in Sources */, 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */, 8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */, CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */, @@ -521,6 +527,7 @@ A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */, C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */, BEBDBED4A7D1D786C47DB8EE /* SyncView.swift in Sources */, + 950B9DB26C77C1E63BCC96F2 /* ThumbnailCache.swift in Sources */, B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */, 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */, 10062AA91BC883AE597443BD /* URL+.swift in Sources */, @@ -532,7 +539,7 @@ /* Begin PBXTargetDependency section */ EA438E47054B868AA6F6727D /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 32316B985B8906FE4CB97730 /* NASBackup */; + target = 32316B985B8906FE4CB97730 /* Kisani */; targetProxy = 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -548,10 +555,11 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + PRODUCT_NAME = KisaniTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kisani.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Kisani"; }; name = Release; }; @@ -561,11 +569,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_NAME = Kisani; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -581,10 +591,11 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + PRODUCT_NAME = KisaniTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kisani.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Kisani"; }; name = Debug; }; @@ -662,11 +673,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_NAME = Kisani; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -736,7 +749,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */ = { + 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "Kisani" */ = { isa = XCConfigurationList; buildConfigurations = ( AAEAFE599F3F20DB264D4528 /* Debug */, @@ -745,7 +758,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */ = { + 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "KisaniTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 7215384C2378F778AC60259A /* Debug */, @@ -754,7 +767,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = { + F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "Kisani" */ = { isa = XCConfigurationList; buildConfigurations = ( 5496D0713D70962E349CB0C8 /* Debug */, diff --git a/NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Kisani.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to Kisani.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/NASBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Kisani.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from NASBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to Kisani.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Services/LANMonitor.swift b/Services/LANMonitor.swift index 6713893..bee53e1 100644 --- a/Services/LANMonitor.swift +++ b/Services/LANMonitor.swift @@ -33,8 +33,12 @@ final class LANMonitor: ObservableObject { if path.status == .satisfied { await self.checkSSID() + if let conn = ConnectionStore.shared.savedConnection { + await self.checkNASReachability(host: conn.host, port: conn.port) + } } else { self.currentSSID = nil + self.nasReachable = nil } } } diff --git a/Services/SFTPService.swift b/Services/SFTPService.swift index 6902665..f959af5 100644 --- a/Services/SFTPService.swift +++ b/Services/SFTPService.swift @@ -80,6 +80,14 @@ final class SFTPService: NASTransferProtocol { } } + func downloadData(at remotePath: String) async throws -> Data { + guard let sftp else { throw BackupError.connectionFailed("Not connected") } + return try await sftp.withFile(filePath: remotePath, flags: [.read]) { file in + let buf = try await file.readAll() + return Data(buf.readableBytesView) + } + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let sftp else { throw BackupError.connectionFailed("Not connected") } let filename = localURL.lastPathComponent diff --git a/Services/SMBService.swift b/Services/SMBService.swift index 67c666c..0b50989 100644 --- a/Services/SMBService.swift +++ b/Services/SMBService.swift @@ -79,6 +79,13 @@ final class SMBService: NASTransferProtocol { return files.contains { $0.name == filename } } + func downloadData(at remotePath: String) async throws -> Data { + guard let client else { throw BackupError.connectionFailed("Not connected") } + let (share, rel) = splitPath(remotePath) + try await client.connectShare(share) + return try await client.download(path: rel) + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) diff --git a/Services/ThumbnailCache.swift b/Services/ThumbnailCache.swift new file mode 100644 index 0000000..b2f5e10 --- /dev/null +++ b/Services/ThumbnailCache.swift @@ -0,0 +1,50 @@ +import UIKit + +final class ThumbnailCache { + static let shared = ThumbnailCache() + + private let memory = NSCache() + private let diskDir: URL + + private init() { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + diskDir = caches.appendingPathComponent("NASThumb", isDirectory: true) + try? FileManager.default.createDirectory(at: diskDir, withIntermediateDirectories: true) + memory.countLimit = 300 + memory.totalCostLimit = 80 * 1024 * 1024 // 80 MB + } + + func get(for path: String) -> UIImage? { + let key = cacheKey(path) + if let img = memory.object(forKey: key as NSString) { return img } + let url = diskDir.appendingPathComponent(key) + guard let data = try? Data(contentsOf: url), + let img = UIImage(data: data) else { return nil } + memory.setObject(img, forKey: key as NSString, cost: data.count) + return img + } + + func set(_ image: UIImage, for path: String) { + let key = cacheKey(path) + let data = image.jpegData(compressionQuality: 0.7) ?? Data() + memory.setObject(image, forKey: key as NSString, cost: data.count) + let url = diskDir.appendingPathComponent(key) + try? data.write(to: url, options: .atomic) + } + + private func cacheKey(_ path: String) -> String { + path + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "-") + } +} + +extension UIImage { + func thumbnailScaled(to size: CGFloat) -> UIImage { + let scale = min(size / self.size.width, size / self.size.height) + guard scale < 1 else { return self } + let newSize = CGSize(width: self.size.width * scale, height: self.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in self.draw(in: CGRect(origin: .zero, size: newSize)) } + } +} diff --git a/Shared/Components/FolderRow.swift b/Shared/Components/FolderRow.swift index f4fcb03..33d586b 100644 --- a/Shared/Components/FolderRow.swift +++ b/Shared/Components/FolderRow.swift @@ -4,41 +4,50 @@ struct FolderRow: View { let item: NASItem let isSelected: Bool let onTap: () -> Void + var onNavigate: (() -> Void)? = nil var body: some View { - Button(action: onTap) { - HStack(spacing: 12) { - Image(systemName: item.isDirectory ? "folder.fill" : "doc") - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.inkSecondary) - .frame(width: 22) + HStack(spacing: 0) { + Button(action: onTap) { + HStack(spacing: 12) { + Image(systemName: item.isDirectory ? "folder.fill" : "doc") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.inkSecondary) + .frame(width: 22) - Text(item.name) - .font(.system(size: 15, weight: isSelected ? .medium : .regular)) - .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.ink) - .lineLimit(1) + Text(item.name) + .font(.system(size: 15, weight: isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.ink) + .lineLimit(1) - Spacer() + Spacer() - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(AppTheme.inkInverse) - } else if item.isDirectory { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.inkInverse) + } + } + .padding(.leading, 16) + .padding(.trailing, item.isDirectory && onNavigate != nil ? 8 : 16) + .padding(.vertical, 12) + .background(isSelected ? AppTheme.ink : Color.clear) + .animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected) + } + .buttonStyle(ScaleButtonStyle()) + + if item.isDirectory, let navigate = onNavigate { + Button(action: navigate) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .foregroundStyle(AppTheme.inkQuaternary) + .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.inkQuaternary) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(isSelected ? AppTheme.ink : Color.clear) + .animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected) } + .buttonStyle(ScaleButtonStyle()) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - isSelected - ? AppTheme.ink - : Color.clear - ) - .animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected) } - .buttonStyle(ScaleButtonStyle()) } } diff --git a/project.yml b/project.yml index 4097b30..b65ad69 100644 --- a/project.yml +++ b/project.yml @@ -41,7 +41,7 @@ targets: info: path: App/Info.plist properties: - CFBundleDisplayName: NASBackup + CFBundleDisplayName: Kisani UILaunchStoryboardName: "" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait @@ -64,7 +64,7 @@ targets: UIBackgroundModes: - fetch - processing - NSPhotoLibraryUsageDescription: NASBackup needs access to your photos to back them up to your NAS. + NSPhotoLibraryUsageDescription: Kisani needs access to your photos to back them up to your NAS. NSFaceIDUsageDescription: Sign in quickly with Face ID. entitlements: path: NASBackup.entitlements