feat: Kisani UI overhaul — brand identity, gallery, visual polish
- Rename app to Kisani (CFBundleDisplayName, xcodeproj → Kisani.xcodeproj) - BackupView: kisani. wordmark + SwiftUI-drawn server logo mark (no asset, adaptive ink/bg), 206pt progress ring with active-state glow, 4-page swipeable ring (progress/pending/failed/uploaded), 4-metric stats strip, friendly NAS card (Home Server label), hamburger → AppMenuView sheet - GalleryView: new NAS + local photo grid with source picker chip, 3-col lazy grid, ThumbnailCache, sync-status dots (green = backed up) - MainTabView: Browse tab → Gallery tab; tab bar uses ultraThinMaterial blur background (systemGray3 icons, 9.5pt labels) - All app icon sizes regenerated from 1024pt source via sips - LANMonitor: nasReachable Bool?, checkNASReachability(host:port:) TCP ping - project.yml: photo library usage description updated to Kisani branding Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
@@ -10,7 +10,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NASBackup</string>
|
||||
<string>Kisani</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -28,7 +28,7 @@
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Sign in quickly with Face ID.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>NASBackup needs access to your photos to back them up to your NAS.</string>
|
||||
<string>Kisani needs access to your photos to back them up to your NAS.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
23
Assets.xcassets/KisaniLogo.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
Assets.xcassets/KisaniLogo.imageset/KisaniLogo@1x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets.xcassets/KisaniLogo.imageset/KisaniLogo@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Assets.xcassets/KisaniLogo.imageset/KisaniLogo@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -23,4 +23,5 @@ protocol NASTransferProtocol: AnyObject {
|
||||
remotePath: String,
|
||||
progress: @escaping (Int64, Int64) -> Void
|
||||
) async throws
|
||||
func downloadData(at remotePath: String) async throws -> Data
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, 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) }
|
||||
}
|
||||
|
||||
429
Features/Browse/GalleryView.swift
Normal file
@@ -0,0 +1,429 @@
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
|
||||
private let videoExts: Set<String> = ["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<String> = []
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; };
|
||||
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; };
|
||||
@@ -98,6 +100,7 @@
|
||||
86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = "<group>"; };
|
||||
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; };
|
||||
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
8D2C125977BE8DD28FBF7F70 /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = "<group>"; };
|
||||
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||
97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
@@ -105,14 +108,14 @@
|
||||
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
|
||||
B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = "<group>"; };
|
||||
BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
|
||||
D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
|
||||
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; };
|
||||
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
@@ -121,6 +124,7 @@
|
||||
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
||||
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; };
|
||||
F928753E2C260E3CF57259DD /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -275,6 +279,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */,
|
||||
F928753E2C260E3CF57259DD /* GalleryView.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
@@ -300,6 +305,7 @@
|
||||
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */,
|
||||
4B67584BB6D705BCB3D40192 /* SFTPService.swift */,
|
||||
DEBD92D10D70B957192E271F /* SMBService.swift */,
|
||||
8D2C125977BE8DD28FBF7F70 /* ThumbnailCache.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
50
Services/ThumbnailCache.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import UIKit
|
||||
|
||||
final class ThumbnailCache {
|
||||
static let shared = ThumbnailCache()
|
||||
|
||||
private let memory = NSCache<NSString, UIImage>()
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||