diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 3f6cc9c..6fd83a9 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -176,20 +176,9 @@ struct BackupView: View { 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") + KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress) + .accessibilityLabel("Backup progress") + .accessibilityValue("\(Int(innerRingProgress * 100)) percent") VStack(spacing: 4) { ringCenterContent @@ -248,37 +237,40 @@ struct BackupView: View { case 1: // Need backup VStack(spacing: 4) { Text("\(notBackedUpDisplayCount)") - .font(.system(size: 44, weight: .semibold, design: .rounded)) + .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) - .monospacedDigit() - Text("need backup") - .font(AppTheme.caption()) + .kerning(-2) + Text("NEED BACKUP") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } case 2: // Failed VStack(spacing: 4) { Text("\(engine.job.failedFiles)") - .font(.system(size: 44, weight: .semibold, design: .rounded)) + .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink) .contentTransition(.numericText()) - .monospacedDigit() - Text("failed") - .font(AppTheme.caption()) + .kerning(-2) + Text("FAILED") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } default: // Backed up VStack(spacing: 4) { Text("\(engine.job.uploadedFiles)") - .font(.system(size: 44, weight: .semibold, design: .rounded)) + .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.positive) .contentTransition(.numericText()) - .monospacedDigit() - Text("backed up") - .font(AppTheme.caption()) + .kerning(-2) + Text("BACKED UP") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } } } @@ -290,23 +282,23 @@ struct BackupView: View { case .idle, .cancelled: if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { VStack(spacing: 8) { - Image(systemName: "checkmark") - .font(.system(size: 30, weight: .medium)) - .foregroundStyle(AppTheme.positive) - Text("All photos safe") - .font(AppTheme.caption()) + ringCheckmark + Text("ALL PHOTOS SAFE") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } } else { VStack(spacing: 6) { Text("\(statusService.snapshot.needBackup)") - .font(.system(size: 44, weight: .semibold, design: .rounded)) + .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) - .monospacedDigit() - Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup") - .font(AppTheme.caption()) + .kerning(-2) + Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS FOUND" : "NEED BACKUP") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } } @@ -315,47 +307,50 @@ struct BackupView: View { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) - Text("Scanning library…") - .font(AppTheme.caption()) + Text("SCANNING LIBRARY") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } case .running, .paused: VStack(spacing: 5) { Text(percentText) - .font(.system(size: 44, weight: .semibold, design: .rounded)) + .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) - .monospacedDigit() - Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") - .font(AppTheme.caption()) + .kerning(-2) + Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) OF \(engine.job.totalFiles)") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkSecondary) + .tracking(1.5).opacity(0.75) if let eta = engine.job.eta { Text("~\(etaString(eta)) remaining") - .font(.system(size: 11, weight: .regular)) + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(1.5).opacity(0.6) .padding(.top, 1) } } case .completed: VStack(spacing: 8) { - Image(systemName: "checkmark") - .font(.system(size: 30, weight: .medium)) - .foregroundStyle(AppTheme.positive) - Text("\(engine.job.uploadedFiles) photos backed up") - .font(AppTheme.caption()) + ringCheckmark + Text("ALL DONE") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } case .failed: VStack(spacing: 8) { Image(systemName: "exclamationmark.circle") - .font(.system(size: 30, weight: .light)) + .font(.system(size: 26, weight: .light)) .foregroundStyle(AppTheme.destructive) - Text("Backup failed") - .font(.system(size: 13, weight: .medium)) + Text("BACKUP FAILED") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.destructive.opacity(0.8)) + .tracking(2).opacity(0.75) } } } @@ -381,9 +376,10 @@ struct BackupView: View { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) - Text("Checking…") - .font(AppTheme.caption()) + Text("CHECKING") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) } } @@ -393,18 +389,41 @@ struct BackupView: View { Image(systemName: "wifi.slash") .font(.system(size: 26, weight: .light)) .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75)) - Text("Waiting for NAS") - .font(.system(size: 13, weight: .medium)) + Text("WAITING FOR NAS") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkSecondary) + .tracking(2).opacity(0.75) if coordinator.pendingAtDisconnect > 0 { - Text("\(coordinator.pendingAtDisconnect) queued") - .font(AppTheme.micro(11)) + Text("\(coordinator.pendingAtDisconnect) QUEUED") + .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) + .tracking(2).opacity(0.75) .contentTransition(.numericText()) } } } + private var ringCheckmark: some View { + ZStack { + Circle() + .stroke(AppTheme.positive, lineWidth: 1) + .frame(width: 22, height: 22) + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(AppTheme.positive) + } + } + + private var outerRingProgress: CGFloat { + let nas = CGFloat(nasArchiveDisplayCount) + let phone = CGFloat(max(1, statusService.snapshot.phoneTotal)) + return min(nas / (nas + phone), 1) + } + + private var innerRingProgress: CGFloat { + CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal)) + } + private var ringColor: Color { if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if case .failed = resolvedStatus { return AppTheme.destructive } @@ -791,3 +810,44 @@ struct BackupView: View { Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } } + +// MARK: — Ring + +private struct KisaniRingView: View { + let outerProgress: CGFloat + let innerProgress: CGFloat + + private let track = Color(red: 0.92, green: 0.92, blue: 0.92) + + var body: some View { + ZStack { + // Outer track + Circle().stroke(track, lineWidth: 1).padding(10) + // Outer progress (slate, animates) + Circle().trim(from: 0, to: outerProgress) + .stroke( + Color(red: 0.58, green: 0.64, blue: 0.73), + style: StrokeStyle(lineWidth: 1, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(10) + .animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress) + + // Middle static ring (gray) + Circle().stroke(Color(red: 0.78, green: 0.78, blue: 0.78), lineWidth: 1).padding(24) + + // Inner track + Circle().stroke(track, lineWidth: 1.5).padding(38) + // Inner progress (green, animates) + Circle().trim(from: 0, to: innerProgress) + .stroke( + AppTheme.positive, + style: StrokeStyle(lineWidth: 1.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(38) + .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) + } + .frame(width: 210, height: 210) + } +}