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 <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-19 17:56:23 +03:00
parent b9b91bbb35
commit 2eaf6620f1

View File

@@ -176,20 +176,9 @@ struct BackupView: View {
private var ringHero: some View { private var ringHero: some View {
ZStack { ZStack {
ProgressRing( KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress)
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") .accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent") .accessibilityValue("\(Int(innerRingProgress * 100)) percent")
VStack(spacing: 4) { VStack(spacing: 4) {
ringCenterContent ringCenterContent
@@ -248,37 +237,40 @@ struct BackupView: View {
case 1: // Need backup case 1: // Need backup
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(notBackedUpDisplayCount)") Text("\(notBackedUpDisplayCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .kerning(-2)
Text("need backup") Text("NEED BACKUP")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
case 2: // Failed case 2: // Failed
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(engine.job.failedFiles)") 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) .foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .kerning(-2)
Text("failed") Text("FAILED")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
default: // Backed up default: // Backed up
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)") Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.positive) .foregroundStyle(AppTheme.positive)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .kerning(-2)
Text("backed up") Text("BACKED UP")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
} }
} }
@@ -290,23 +282,23 @@ struct BackupView: View {
case .idle, .cancelled: case .idle, .cancelled:
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "checkmark") ringCheckmark
.font(.system(size: 30, weight: .medium)) Text("ALL PHOTOS SAFE")
.foregroundStyle(AppTheme.positive) .font(.system(size: 8, weight: .regular, design: .monospaced))
Text("All photos safe")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
} else { } else {
VStack(spacing: 6) { VStack(spacing: 6) {
Text("\(statusService.snapshot.needBackup)") Text("\(statusService.snapshot.needBackup)")
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .kerning(-2)
Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup") Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS FOUND" : "NEED BACKUP")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
} }
@@ -315,47 +307,50 @@ struct BackupView: View {
ProgressView() ProgressView()
.scaleEffect(0.85) .scaleEffect(0.85)
.tint(AppTheme.inkTertiary) .tint(AppTheme.inkTertiary)
Text("Scanning library…") Text("SCANNING LIBRARY")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
case .running, .paused: case .running, .paused:
VStack(spacing: 5) { VStack(spacing: 5) {
Text(percentText) Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .kerning(-2)
Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) OF \(engine.job.totalFiles)")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
.tracking(1.5).opacity(0.75)
if let eta = engine.job.eta { if let eta = engine.job.eta {
Text("~\(etaString(eta)) remaining") Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(1.5).opacity(0.6)
.padding(.top, 1) .padding(.top, 1)
} }
} }
case .completed: case .completed:
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "checkmark") ringCheckmark
.font(.system(size: 30, weight: .medium)) Text("ALL DONE")
.foregroundStyle(AppTheme.positive) .font(.system(size: 8, weight: .regular, design: .monospaced))
Text("\(engine.job.uploadedFiles) photos backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
case .failed: case .failed:
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "exclamationmark.circle") Image(systemName: "exclamationmark.circle")
.font(.system(size: 30, weight: .light)) .font(.system(size: 26, weight: .light))
.foregroundStyle(AppTheme.destructive) .foregroundStyle(AppTheme.destructive)
Text("Backup failed") Text("BACKUP FAILED")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.destructive.opacity(0.8)) .foregroundStyle(AppTheme.destructive.opacity(0.8))
.tracking(2).opacity(0.75)
} }
} }
} }
@@ -381,9 +376,10 @@ struct BackupView: View {
ProgressView() ProgressView()
.scaleEffect(0.85) .scaleEffect(0.85)
.tint(AppTheme.inkTertiary) .tint(AppTheme.inkTertiary)
Text("Checking…") Text("CHECKING")
.font(AppTheme.caption()) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
} }
@@ -393,18 +389,41 @@ struct BackupView: View {
Image(systemName: "wifi.slash") Image(systemName: "wifi.slash")
.font(.system(size: 26, weight: .light)) .font(.system(size: 26, weight: .light))
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75)) .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75))
Text("Waiting for NAS") Text("WAITING FOR NAS")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
.tracking(2).opacity(0.75)
if coordinator.pendingAtDisconnect > 0 { if coordinator.pendingAtDisconnect > 0 {
Text("\(coordinator.pendingAtDisconnect) queued") Text("\(coordinator.pendingAtDisconnect) QUEUED")
.font(AppTheme.micro(11)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
.contentTransition(.numericText()) .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 { private var ringColor: Color {
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if case .failed = resolvedStatus { return AppTheme.destructive } 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" 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)
}
}