Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa 59bdad803f 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>
2026-05-17 11:17:13 +03:00

574 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@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(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) {
// 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, 44)
// Stats
statsStrip
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
// flex zone stats cards
Spacer(minLength: 20).frame(maxHeight: 48)
// 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, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// CTA
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
// bottom absorber soaks up remaining height to anchor cards+CTA
Spacer(minLength: 0)
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.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)
await loadNASCount()
}
}
// MARK: Logo mark (drawn in code no asset dependency)
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
private var ringHero: some View {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 8,
size: 206,
color: ringColor,
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")
VStack(spacing: 4) {
ringCenterContent
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringPage)
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringPage)
}
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { value in
if value.translation.width < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0: // Progress / status
VStack(spacing: 4) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
ringStatusLabel
}
case 1: // Pending
VStack(spacing: 4) {
Text("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("pending")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case 2: // Failed
VStack(spacing: 4) {
Text("\(engine.job.failedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("failed")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
default: // Completed
VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.positive)
.contentTransition(.numericText())
.monospacedDigit()
Text("uploaded")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
@ViewBuilder
private var ringStatusLabel: some View {
switch engine.job.status {
case .running:
VStack(spacing: 2) {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta {
Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
}
}
case .preparing:
Text("Preparing…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
case .completed:
Text("Done")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.positive)
case .failed:
Text("Failed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
case .cancelled:
Text("Cancelled")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
default:
Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
// MARK: Page indicator dots
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Circle()
.fill(i == ringPage ? AppTheme.ink : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 5 : 4, height: i == ringPage ? 5 : 4)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: ringPage)
}
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.positive
case .failed: return AppTheme.destructive
case .running, .paused,
.preparing: return Color(red: 1.0, green: 0.58, blue: 0.0)
default: return AppTheme.inkTertiary.opacity(0.35)
}
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Compact stats strip
private var statsStrip: some View {
VStack(spacing: 8) {
HStack(spacing: 0) {
compactStat(
nasFileCount.map { "\($0)" } ?? "",
label: "in NAS",
color: AppTheme.interactive
)
thinDivider
compactStat(
"\(vm.totalPhotoCount)",
label: "on iPhone",
color: AppTheme.inkSecondary
)
thinDivider
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
)
}
.frame(height: 56)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
pageIndicator
}
}
private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 3) {
Text(value)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
private var thinDivider: some View {
Rectangle()
.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
private var nasStatusRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
if let conn = store.savedConnection {
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
VStack(alignment: .leading, spacing: 1) {
Text(friendlyName)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("No storage connected")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(nasStatusDotColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
Text(nasStatusLabel)
.font(AppTheme.micro())
.foregroundStyle(nasStatusDotColor)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
}
if lanMonitor.nasReachable == false {
Text("Unreachable")
.font(.system(size: 9, weight: .regular))
.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))
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
// MARK: Photos access nudge
private var photosAccessRow: some View {
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack(spacing: 10) {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
.frame(width: 20)
Text("Grant photo library access to enable backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(AppTheme.destructive.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
// MARK: Action button
private var actionButton: some View {
Group {
switch engine.job.status {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(engine.job.status == .completed ? "Back up again" : "Start backup")
}
.buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 50)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
}
}