diff --git a/Core/Protocols/NASTransferProtocol.swift b/Core/Protocols/NASTransferProtocol.swift index e397752..e3dbd40 100644 --- a/Core/Protocols/NASTransferProtocol.swift +++ b/Core/Protocols/NASTransferProtocol.swift @@ -14,6 +14,7 @@ protocol NASTransferProtocol: AnyObject { func connect(to host: String, port: Int, username: String, password: String) async throws func disconnect() + func listShares() async throws -> [String] func listDirectory(at path: String) async throws -> [NASItem] func createDirectory(at path: String) async throws func fileExists(at remotePath: String) async throws -> Bool diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift index d8ba12d..d411719 100644 --- a/Features/Browse/BrowseView.swift +++ b/Features/Browse/BrowseView.swift @@ -18,13 +18,13 @@ struct BrowseView: View { AppTheme.background.ignoresSafeArea() VStack(spacing: 0) { - // Path bar - HStack(spacing: 8) { + // Path breadcrumb bar + HStack(spacing: 6) { Image(systemName: "externaldrive") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkTertiary) Text(currentPath) - .font(AppTheme.caption()) + .font(.system(size: 12, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .lineLimit(1) .truncationMode(.middle) @@ -39,71 +39,13 @@ struct BrowseView: View { .frame(height: 0.5) if isLoading { - Spacer() - VStack(spacing: 10) { - ProgressView() - .tint(AppTheme.inkTertiary) - Text("Loading…") - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkTertiary) - } - Spacer() - } else if let error { - Spacer() - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28, weight: .ultraLight)) - .foregroundStyle(AppTheme.inkQuaternary) - VStack(spacing: 4) { - Text("Could not load folder") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(AppTheme.inkSecondary) - Text(error) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkTertiary) - .multilineTextAlignment(.center) - } - } - .padding(.horizontal, 32) - Spacer() + loadingState + } else if let err = error { + errorState(err) + } else if items.isEmpty { + emptyState } else { - List { - if currentPath != "/" { - Button(action: navigateUp) { - HStack(spacing: 10) { - Image(systemName: "arrow.up") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(AppTheme.inkSecondary) - .frame(width: 22) - Text("Parent folder") - .font(AppTheme.body()) - .foregroundStyle(AppTheme.inkSecondary) - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .buttonStyle(PlainButtonStyle()) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } - - ForEach(items) { item in - FolderRow( - item: item, - isSelected: selectedPath == item.path - ) { - if item.isDirectory { - navigate(to: item.path) - } else { - selectedPath = item.path - } - } - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } - } - .listStyle(.plain) + folderList } } } @@ -123,15 +65,11 @@ struct BrowseView: View { Image(systemName: "folder.badge.plus") .foregroundStyle(AppTheme.inkSecondary) } + .disabled(currentPath == "/" && connection.nasProtocol == .smb) } - ToolbarItem(placement: .bottomBar) { - Button("Select this folder") { - selectedPath = currentPath - dismiss() - } - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(AppTheme.ink) - } + } + .safeAreaInset(edge: .bottom) { + selectButton } .alert("New folder", isPresented: $showCreateFolder) { TextField("Folder name", text: $newFolderName) @@ -139,17 +77,193 @@ struct BrowseView: View { Button("Cancel", role: .cancel) {} } } - .task { await loadDirectory(path: currentPath) } + .task { await loadItems(path: currentPath) } } - private func loadDirectory(path: String) async { + // MARK: — Sub-views + + private var loadingState: some View { + VStack { + Spacer() + VStack(spacing: 12) { + ProgressView() + .tint(AppTheme.inkTertiary) + .scaleEffect(1.1) + Text("Loading…") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + } + } + + private func errorState(_ message: String) -> some View { + VStack { + Spacer() + VStack(spacing: 20) { + // Icon + ZStack { + Circle() + .fill(AppTheme.surfaceSunken) + .frame(width: 64, height: 64) + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(AppTheme.inkSecondary) + } + + VStack(spacing: 6) { + Text("Could not load folder") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.ink) + Text(message) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + Button(action: { Task { await loadItems(path: currentPath) } }) { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + Text("Try again") + .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()) + } + Spacer() + } + } + + private var emptyState: some View { + VStack { + Spacer() + VStack(spacing: 10) { + Image(systemName: "folder") + .font(.system(size: 28, weight: .ultraLight)) + .foregroundStyle(AppTheme.inkQuaternary) + Text("Empty folder") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + } + } + + private var folderList: some View { + List { + if currentPath != "/" { + Button(action: navigateUp) { + HStack(spacing: 10) { + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 22) + Text("Parent folder") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkSecondary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + } + .buttonStyle(ScaleButtonStyle()) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + + ForEach(items) { item in + FolderRow( + item: item, + isSelected: selectedPath == item.path + ) { + if item.isDirectory { + navigate(to: item.path) + } else { + selectedPath = item.path + } + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .background(AppTheme.background) + } + + private var selectButton: some View { + VStack(spacing: 0) { + Rectangle() + .fill(AppTheme.separator) + .frame(height: 0.5) + + HStack(spacing: 12) { + // Current path chip + HStack(spacing: 5) { + Image(systemName: "folder.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(selectedPath == currentPath ? AppTheme.interactive : AppTheme.inkQuaternary) + Text(currentPath == "/" ? "Root" : (currentPath as NSString).lastPathComponent) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedPath == currentPath ? AppTheme.interactive : AppTheme.inkTertiary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(selectedPath == currentPath ? AppTheme.interactive.opacity(0.1) : AppTheme.surfaceSunken) + ) + + Spacer() + + Button(action: { + selectedPath = currentPath + dismiss() + }) { + Text("Select") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.inkInverse) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(currentPath == "/" && connection.nasProtocol == .smb ? AppTheme.inkQuaternary : AppTheme.ink) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(ScaleButtonStyle()) + .disabled(currentPath == "/" && connection.nasProtocol == .smb) + } + .padding(.horizontal, AppTheme.hPad) + .padding(.vertical, 12) + .background(AppTheme.background) + } + } + + // MARK: — Data + + private func loadItems(path: String) async { isLoading = true error = nil + items = [] do { let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() try await s.connect(to: connection.host, port: connection.port, username: connection.username, password: connection.password) - items = try await s.listDirectory(at: path).filter { $0.isDirectory } + if path == "/" && connection.nasProtocol == .smb { + let shareNames = try await s.listShares() + items = shareNames.map { name in + NASItem(name: name, path: "/\(name)", isDirectory: true, size: 0, modifiedDate: nil) + } + } else { + items = try await s.listDirectory(at: path).filter { $0.isDirectory } + } s.disconnect() } catch let e as BackupError { self.error = e.errorDescription @@ -161,7 +275,7 @@ struct BrowseView: View { private func navigate(to path: String) { currentPath = path - Task { await loadDirectory(path: path) } + Task { await loadItems(path: path) } } private func navigateUp() { @@ -171,14 +285,14 @@ struct BrowseView: View { private func createFolder() async { guard !newFolderName.isEmpty else { return } - let newPath = "\(currentPath)/\(newFolderName)" + let newPath = currentPath == "/" ? "/\(newFolderName)" : "\(currentPath)/\(newFolderName)" do { let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() try await s.connect(to: connection.host, port: connection.port, username: connection.username, password: connection.password) try await s.createDirectory(at: newPath) s.disconnect() - await loadDirectory(path: currentPath) + await loadItems(path: currentPath) } catch {} } } diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift index 60e7d39..f8ad362 100644 --- a/Features/Login/LoginView.swift +++ b/Features/Login/LoginView.swift @@ -3,7 +3,7 @@ import SwiftUI struct LoginView: View { @EnvironmentObject var store: ConnectionStore @StateObject private var vm = LoginViewModel() - @State private var logoAppeared = false + @State private var appeared = false var body: some View { ZStack { @@ -12,70 +12,79 @@ struct LoginView: View { VStack(spacing: 0) { Spacer() - // Logo - Image("nas_logo_clean") - .resizable() - .scaledToFit() - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4) - .scaleEffect(logoAppeared ? 1 : 0.8) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared) + // Logo + wordmark + VStack(spacing: 20) { + Image("nas_logo_clean") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 20, x: 0, y: 6) + .scaleEffect(appeared ? 1 : 0.85) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.72).delay(0.08), value: appeared) - Spacer().frame(height: 32) + VStack(spacing: 6) { + Text("Kisani") + .font(.system(size: 32, weight: .bold, design: .default)) + .foregroundStyle(AppTheme.ink) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.45, dampingFraction: 0.8).delay(0.16), value: appeared) - // Title + account - VStack(spacing: 5) { - Text("Kisani") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(AppTheme.ink) - Text("Sign in to continue") - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkTertiary) - } - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared) - - Spacer().frame(height: 40) - - // Face ID button - Button(action: { vm.authenticateWithFaceID() }) { - HStack(spacing: 8) { - if vm.isAuthenticating { - ProgressView() - .tint(AppTheme.inkInverse) - .scaleEffect(0.8) - } else { - Image(systemName: "faceid") - .font(.system(size: 16, weight: .medium)) - Text("Sign in with Face ID") - } + Text("Your NAS, always in sync.") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.45, dampingFraction: 0.8).delay(0.22), value: appeared) } } - .buttonStyle(PrimaryButtonStyle()) - .padding(.horizontal, AppTheme.hPad) - .disabled(vm.isAuthenticating) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared) - if let error = vm.authError { - Text(error) - .font(AppTheme.caption()) + Spacer().frame(height: 52) + + // Auth button + error + VStack(spacing: 12) { + Button(action: { vm.authenticateWithFaceID() }) { + HStack(spacing: 8) { + if vm.isAuthenticating { + ProgressView() + .tint(AppTheme.inkInverse) + .scaleEffect(0.85) + } else { + Image(systemName: "faceid") + .font(.system(size: 17, weight: .medium)) + Text("Sign in with Face ID") + .font(.system(size: 15, weight: .semibold)) + } + } + } + .buttonStyle(PrimaryButtonStyle()) + .padding(.horizontal, AppTheme.hPad) + .disabled(vm.isAuthenticating) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.45, dampingFraction: 0.8).delay(0.28), value: appeared) + + if let error = vm.authError { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 12)) + Text(error) + .font(AppTheme.caption()) + } .foregroundStyle(AppTheme.destructive) - .padding(.top, 12) .transition(.opacity.combined(with: .move(edge: .top))) + } } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.authError != nil) Spacer() - // More + // More options Button("More options") { vm.showMoreSheet = true } .font(.system(size: 13, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) - .padding(.bottom, 44) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared) + .padding(.bottom, 48) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.45, dampingFraction: 0.8).delay(0.36), value: appeared) } } .sheet(isPresented: $vm.showMoreSheet) { @@ -88,7 +97,7 @@ struct LoginView: View { } .onAppear { vm.onAuthenticated = { store.isSessionActive = true } - logoAppeared = true + appeared = true } } } @@ -119,7 +128,7 @@ struct MoreOptionsSheet: View { .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) - .stroke(AppTheme.cardBorderColor, lineWidth: 0.5) + .stroke(AppTheme.ink.opacity(0.08), lineWidth: 0.5) ) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 16) diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index ba67341..ad986cb 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor + @State private var showSignOutConfirm = false var body: some View { ZStack { @@ -17,6 +18,7 @@ struct SettingsView: View { connectivitySection remoteAccessSection notificationSection + signOutSection } .padding(.horizontal, AppTheme.hPad) .padding(.top, 20) @@ -25,6 +27,16 @@ struct SettingsView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Sign out of Kisani?", + isPresented: $showSignOutConfirm, + titleVisibility: .visible + ) { + Button("Sign Out & Forget NAS", role: .destructive) { store.signOut() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Your NAS connection and backup settings will be removed from this device.") + } } // MARK: — Appearance @@ -305,4 +317,38 @@ struct SettingsView: View { .frame(height: 0.5) .padding(.leading, AppTheme.cardPad) } + + // MARK: — Sign Out + + private var signOutSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("ACCOUNT") + + Button(action: { showSignOutConfirm = true }) { + HStack(spacing: 12) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.destructive) + .frame(width: 20) + Text("Sign Out") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.destructive) + Spacer() + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 14) + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + } + .buttonStyle(ScaleButtonStyle()) + } + } + + private func sectionLabel(_ text: String) -> some View { + Text(text) + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + .padding(.leading, 4) + } } diff --git a/Services/SFTPService.swift b/Services/SFTPService.swift index c91aca5..6902665 100644 --- a/Services/SFTPService.swift +++ b/Services/SFTPService.swift @@ -34,6 +34,8 @@ final class SFTPService: NASTransferProtocol { isConnected = false } + func listShares() async throws -> [String] { [] } + func listDirectory(at path: String) async throws -> [NASItem] { guard let sftp else { throw BackupError.connectionFailed("Not connected") } do { diff --git a/Services/SMBService.swift b/Services/SMBService.swift index fb80303..67c666c 100644 --- a/Services/SMBService.swift +++ b/Services/SMBService.swift @@ -32,6 +32,18 @@ final class SMBService: NASTransferProtocol { isConnected = false } + func listShares() async throws -> [String] { + guard let client else { throw BackupError.connectionFailed("Not connected") } + let shares = try await client.listShares() + return shares + .filter { share in + !share.name.hasSuffix("$") && + !share.type.contains(.ipc) && + !share.type.contains(.special) + } + .map { $0.name } + } + func listDirectory(at path: String) async throws -> [NASItem] { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(path) diff --git a/Shared/Components/FolderRow.swift b/Shared/Components/FolderRow.swift index 08eb0dc..f4fcb03 100644 --- a/Shared/Components/FolderRow.swift +++ b/Shared/Components/FolderRow.swift @@ -8,14 +8,14 @@ struct FolderRow: View { var body: some View { Button(action: onTap) { HStack(spacing: 12) { - Image(systemName: item.isDirectory ? "folder" : "doc") - .font(.system(size: 15, weight: .regular)) - .foregroundStyle(isSelected ? .white : AppTheme.inkSecondary) + 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 ? .white : AppTheme.ink) + .foregroundStyle(isSelected ? AppTheme.inkInverse : AppTheme.ink) .lineLimit(1) Spacer() @@ -23,7 +23,7 @@ struct FolderRow: View { if isSelected { Image(systemName: "checkmark") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(AppTheme.inkInverse) } else if item.isDirectory { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) @@ -39,6 +39,6 @@ struct FolderRow: View { ) .animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(ScaleButtonStyle()) } } diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 89aaaf0..63ea4cb 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -104,6 +104,12 @@ final class ConnectionStore: ObservableObject { UserDefaults.standard.removeObject(forKey: "historyEntries") } + func signOut() { + isSessionActive = false + onboardingPhotoShown = false + savedConnection = nil + } + var isInQuietHours: Bool { guard quietHoursEnabled else { return false } let hour = Calendar.current.component(.hour, from: Date()) diff --git a/Tests/MockNASService.swift b/Tests/MockNASService.swift index 2a42c8f..f23a7f3 100644 --- a/Tests/MockNASService.swift +++ b/Tests/MockNASService.swift @@ -21,6 +21,8 @@ final class MockNASService: NASTransferProtocol { func disconnect() { isConnected = false } + func listShares() async throws -> [String] { [] } + func listDirectory(at path: String) async throws -> [NASItem] { if shouldFail { throw MockError.connectionFailed } return fakeItems