From 2eaf6620f102036a986b549e78eb9d9653152165 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Tue, 19 May 2026 17:56:23 +0300 Subject: [PATCH] feat: replace ProgressRing with KisaniRingView, update ring typography Translates the React KisaniCircle design to SwiftUI: three concentric thin rings (outer slate progress, middle static gray, inner green progress) replace the old single ProgressRing. All ring center text updated to ultraLight 46pt numbers with 8pt monospaced uppercase labels. Adds ringCheckmark (22px circle + tiny checkmark) for completed/all-safe states. Updates checking, disconnected, failed center views to match. Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Features/Backup/BackupView.swift | 172 +++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 56 deletions(-) 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) + } +}