diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index ed82b45..5663f44 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -42,50 +42,36 @@ struct BackupView: View { ZStack(alignment: .topTrailing) { AppTheme.background.ignoresSafeArea() - // ─── Scrollable content with pull-to-refresh ────────────── - GeometryReader { geo in + VStack(spacing: 0) { + + // ─── Brand ──────────────────────────────────────────── + VStack(spacing: 10) { + Text("kisani.") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(AppTheme.ink) + .kerning(2) + KisaniLogoMark(size: 66) + } + .frame(maxWidth: .infinity) + .padding(.top, 16) + + Spacer(minLength: 16).frame(maxHeight: 40) + + // ─── Hero — outside ScrollView so pull gesture fires ─── + heroView + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage) + + // ─── Scrollable stats / detail ───────────────────────── ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 0) { - - // ─── Brand ──────────────────────────────────── - VStack(spacing: 10) { - Text("kisani.") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(AppTheme.ink) - .kerning(2) - KisaniLogoMark(size: 66) - } - .frame(maxWidth: .infinity) - .padding(.top, 16) - - Spacer(minLength: 24).frame(maxHeight: 48) - - // ─── Per-page content ───────────────────────── - if ringPage == 0 { - page0Content - } else if ringPage == 1 { - page1Content - } else { - page2Content - } - - Spacer(minLength: 0) - - // ─── Page navigation ────────────────────────── - pageNav - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 24) - } - .frame(minHeight: geo.size.height) - } - .refreshable { - await statusService.refreshAndWait(force: true) - if statusService.snapshot.needBackup == 0 { - engine.resolveSuccess() - } - // handleReconciliationComplete() fires via Combine and will - // start auto-backup if needed — no explicit call required here. + belowContent + .padding(.bottom, 16) } + .refreshable { await triggerRefreshAndBackup() } + + // ─── Page navigation ─────────────────────────────────── + pageNav + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 24) } // ─── Menu button — floats above scroll content ───────────── @@ -492,6 +478,82 @@ struct BackupView: View { Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } } + private func triggerRefreshAndBackup() async { + await statusService.refreshAndWait(force: true) + if statusService.snapshot.needBackup == 0 { + engine.resolveSuccess() + return + } + if engine.job.status == .paused { + engine.resume() + } else if !engine.job.status.isActive { + startBackup() + } + } + + // MARK: — Hero / below dispatch + + @ViewBuilder + private var heroView: some View { + switch ringPage { + case 1: + ZStack { + StorageRingView(usedProgress: usedFraction, safeProgress: safeFraction) + VStack(spacing: 6) { + Image(systemName: "iphone") + .font(.system(size: 14, weight: .ultraLight)) + .foregroundStyle(AppTheme.inkTertiary) + Text("\(Int(usedFraction * 100))%") + .font(.system(size: 46, weight: .ultraLight)) + .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0)) + .kerning(-2) + Text("STORAGE USED") + .font(.system(size: 8, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) + } + } + .gesture(pageSwitchGesture) + .padding(.bottom, 8) + case 2: + HStack(alignment: .lastTextBaseline, spacing: 6) { + Text(String(format: "%.1f", totalFreeableGB)) + .font(.system(size: 64, weight: .ultraLight)) + .foregroundStyle(AppTheme.positive) + .kerning(-2) + Text("GB FREED") + .font(.system(size: 8, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.inkTertiary) + .tracking(2) + .offset(y: -10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 8) + .gesture(pageSwitchGesture) + default: + circleSection + } + } + + @ViewBuilder + private var belowContent: some View { + switch ringPage { + case 1: + page1BelowContent + case 2: + page2BelowContent + default: + statsStrip + .padding(.horizontal, AppTheme.hPad) + .padding(.top, 4) + Spacer(minLength: 16).frame(maxHeight: 32) + nasStatusRow + .padding(.horizontal, AppTheme.hPad) + Spacer(minLength: 8).frame(maxHeight: 20) + } + } + private func etaString(_ seconds: TimeInterval) -> String { Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } @@ -588,7 +650,7 @@ struct BackupView: View { // Ripple expands outward on backup trigger Circle() .stroke(AppTheme.positive, lineWidth: 1.5) - .frame(width: 258, height: 258) + .frame(width: 288, height: 288) .scaleEffect(rippleScale) .opacity(rippleOpacity) .allowsHitTesting(false) @@ -770,40 +832,10 @@ struct BackupView: View { // MARK: — Page content - @ViewBuilder - private var page0Content: some View { - circleSection - statsStrip - .padding(.horizontal, AppTheme.hPad) - .transition(.opacity.combined(with: .scale(scale: 0.97))) - Spacer(minLength: 16).frame(maxHeight: 32) - nasStatusRow - .padding(.horizontal, AppTheme.hPad) - Spacer(minLength: 8).frame(maxHeight: 20) - } + // MARK: — Page below-content @ViewBuilder - private var page1Content: some View { - // Storage ring - ZStack { - StorageRingView(usedProgress: usedFraction, safeProgress: safeFraction) - VStack(spacing: 6) { - Image(systemName: "iphone") - .font(.system(size: 14, weight: .ultraLight)) - .foregroundStyle(AppTheme.inkTertiary) - Text("\(Int(usedFraction * 100))%") - .font(.system(size: 46, weight: .ultraLight)) - .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0)) - .kerning(-2) - Text("STORAGE USED") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2).opacity(0.75) - } - } - .gesture(pageSwitchGesture) - .padding(.bottom, 20) - + private var page1BelowContent: some View { // 3-col stats HStack(spacing: 0) { storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED", @@ -852,24 +884,7 @@ struct BackupView: View { } @ViewBuilder - private var page2Content: some View { - // Freed amount - HStack(alignment: .lastTextBaseline, spacing: 6) { - Text(String(format: "%.1f", totalFreeableGB)) - .font(.system(size: 64, weight: .ultraLight)) - .foregroundStyle(AppTheme.positive) - .kerning(-2) - Text("GB FREED") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2) - .offset(y: -10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 20) - .gesture(pageSwitchGesture) - + private var page2BelowContent: some View { // Breakdown VStack(spacing: 0) { cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive) @@ -1027,7 +1042,7 @@ private struct KisaniRingView: View { .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) .animation(.easeInOut(duration: 0.5), value: innerColor) } - .frame(width: 240, height: 240) + .frame(width: 270, height: 270) } } @@ -1064,6 +1079,6 @@ private struct StorageRingView: View { .padding(38) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress) } - .frame(width: 240, height: 240) + .frame(width: 270, height: 270) } } diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 43f05db..268a57e 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -66,12 +66,15 @@ final class BackupStatusService: NSObject, ObservableObject { } func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { - let uploaded = entries.count - snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) - // nasArchiveTotal is NOT updated optimistically — writeManifest will update - // NASManifestCache with the full merged count, and refresh(force: true) reads it. + let safe = entries.count + // Only count files actually uploaded (fileSize > 0) toward nasArchiveTotal. + // Collision-skipped and fileExists-skipped entries have fileSize 0 and were + // already counted in the last directory scan — adding them would double-count. + let newlyOnNAS = entries.filter { $0.fileSize > 0 }.count + snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + safe) + snapshot.nasArchiveTotal += newlyOnNAS saveSnapshot() - log.info("Post-backup optimistic update: +\(uploaded) safe") + log.info("Post-backup optimistic update: +\(safe) safe, +\(newlyOnNAS) NAS") Task { await writeManifest(entries: entries, connection: connection) refresh(force: true)