feat: Sync tab, hamburger menu, network status, browse UX
- Replace History tab with Sync tab (file list, NAS status, free-up-space retention) - Add AppMenuView hamburger sheet (History, Errors, Connection Status, Sync Activity) - BackupView: Kisani logo chip in toolbar, hamburger menu button, swipeable ring pages (progress/pending/failed/uploaded), always-visible compact stats strip - SettingsView: NETWORK section (SSID, NAS reachability, trusted-network warning, refresh button) - BrowseView: pull-to-refresh, long-press context menu on items - LANMonitor: nasReachable, nasCheckTime, checkNASReachability(host:port:) - ConnectionStore: localRetentionDays (0/60/90/365 days) - Regenerate xcodeproj with xcodegen to include new files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,14 @@ 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 ringPage = 0
|
||||
|
||||
private let ringPageCount = 4
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
@@ -15,27 +21,26 @@ struct BackupView: View {
|
||||
|
||||
// ─── Ring hero ───────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 28)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// ─── Completion summary (only on .completed) ─────────
|
||||
if case .completed = engine.job.status {
|
||||
completionStrip
|
||||
.padding(.bottom, 28)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
}
|
||||
// ─── Compact stats strip (always visible) ─────────────
|
||||
statsStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 20)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// ─── NAS status ───────────────────────────────────────
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// ─── Photos access nudge ──────────────────────────────
|
||||
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
||||
photosAccessRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.bottom, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
@@ -45,11 +50,48 @@ struct BackupView: View {
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.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")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMenu) {
|
||||
AppMenuView()
|
||||
.environmentObject(store)
|
||||
.environmentObject(lanMonitor)
|
||||
}
|
||||
.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: — Logo chip
|
||||
|
||||
private var logoChip: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
Text("Kisani")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Ring hero
|
||||
|
||||
private var ringHero: some View {
|
||||
@@ -64,22 +106,79 @@ struct BackupView: View {
|
||||
.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
|
||||
}
|
||||
|
||||
ringSubtitle
|
||||
.transition(.opacity)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var ringSubtitle: some View {
|
||||
private var ringStatusLabel: some View {
|
||||
switch engine.job.status {
|
||||
case .running:
|
||||
VStack(spacing: 2) {
|
||||
@@ -121,6 +220,19 @@ struct BackupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -131,45 +243,49 @@ struct BackupView: View {
|
||||
|
||||
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
||||
|
||||
// MARK: — Completion strip
|
||||
// MARK: — Compact stats 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
|
||||
)
|
||||
private var statsStrip: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
compactStat("\(engine.job.uploadedFiles)", label: "up", icon: "arrow.up.circle.fill", color: AppTheme.positive)
|
||||
thinDivider
|
||||
compactStat("\(engine.job.skippedFiles)", label: "skip", icon: "minus.circle.fill", color: AppTheme.inkQuaternary)
|
||||
thinDivider
|
||||
compactStat("\(engine.job.failedFiles)", label: "err", icon: "xmark.circle.fill",
|
||||
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary)
|
||||
thinDivider
|
||||
compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", icon: "clock.fill", color: AppTheme.inkSecondary)
|
||||
}
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
pageIndicator
|
||||
}
|
||||
.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) {
|
||||
private func compactStat(_ value: String, label: String, icon: String, color: Color) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(color.opacity(0.7))
|
||||
Text(value)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(valueColor)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(AppTheme.micro())
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var thinDivider: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(width: 0.5)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: — NAS status row
|
||||
@@ -187,12 +303,20 @@ struct BackupView: View {
|
||||
|
||||
if let conn = store.savedConnection {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(conn.host)
|
||||
Text(conn.displayName)
|
||||
.font(AppTheme.headline())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(conn.nasProtocol.rawValue)
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
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))
|
||||
}
|
||||
} else {
|
||||
Text("No NAS configured")
|
||||
@@ -202,13 +326,20 @@ struct BackupView: View {
|
||||
|
||||
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)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
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)
|
||||
}
|
||||
if let reachable = lanMonitor.nasReachable {
|
||||
Text(reachable ? "NAS reachable" : "NAS offline")
|
||||
.font(.system(size: 9, weight: .regular))
|
||||
.foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.opacity(0.7))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
Reference in New Issue
Block a user