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 d9940c9..5487329 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png index 50bf941..35cb776 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png index 9734b26..87060b0 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png index 8132dc3..346881a 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png index 57ab178..3e88b82 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png index 1b8ff20..b08c004 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png index 1b8ff20..b08c004 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png index a5e12d5..94bb47d 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png and b/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ 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 0000000..375a73b Binary files /dev/null and b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@1x.png differ diff --git a/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png new file mode 100644 index 0000000..b3f7d9f Binary files /dev/null and b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png differ diff --git a/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png new file mode 100644 index 0000000..fc7aeaf Binary files /dev/null and b/Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png differ diff --git a/Core/Protocols/NASTransferProtocol.swift b/Core/Protocols/NASTransferProtocol.swift index e3dbd40..b3c8df8 100644 --- a/Core/Protocols/NASTransferProtocol.swift +++ b/Core/Protocols/NASTransferProtocol.swift @@ -23,4 +23,5 @@ protocol NASTransferProtocol: AnyObject { remotePath: String, progress: @escaping (Int64, Int64) -> 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