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>
This commit is contained in:
Robin Kutesa
2026-05-17 11:17:13 +03:00
parent 6cad8d9d6c
commit 59bdad803f
32 changed files with 983 additions and 227 deletions

View File

@@ -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))
}
}
}

View File

@@ -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) }
}

View 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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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 }

View File

@@ -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