Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa ae38f6e417 feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
  LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
  drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
  step indicator (2/3, 3/3), spring entrance animations, and UGreen
  connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
  now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
  onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
  PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00

297 lines
11 KiB
Swift

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@StateObject private var vm = BackupViewModel()
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
// Ring hero
ringHero
.padding(.bottom, 28)
// Completion summary (only on .completed)
if case .completed = engine.job.status {
completionStrip
.padding(.bottom, 28)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
}
Spacer(minLength: 0)
// NAS status
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Action
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 32)
}
}
.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) }
}
// MARK: Ring hero
private var ringHero: some View {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 7,
size: 220,
color: ringColor,
backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
)
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
VStack(spacing: 4) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
ringSubtitle
.transition(.opacity)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
@ViewBuilder
private var ringSubtitle: 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)
}
Text(engine.job.currentFileName)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160)
}
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)
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.positive
case .failed: return AppTheme.destructive
default: return AppTheme.ink
}
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Completion strip
private var completionStrip: some View {
HStack(spacing: 0) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
thinDivider
statCell("\(engine.job.skippedFiles)", label: "skipped")
thinDivider
statCell(
"\(engine.job.failedFiles)",
label: "errors",
valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink
)
}
.padding(.horizontal, AppTheme.hPad)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
}
private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View {
VStack(spacing: 3) {
Text(value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(valueColor)
.monospacedDigit()
Text(label)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(width: 0.5)
.padding(.vertical, 10)
}
// 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 {
VStack(alignment: .leading, spacing: 1) {
Text(conn.host)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text(conn.nasProtocol.rawValue)
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("No NAS configured")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
HStack(spacing: 5) {
Circle()
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6)
Text(engine.job.status.isActive ? "Active" : "Ready")
.font(AppTheme.micro())
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
}
}
.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"
}
}