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