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 {
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)
}
}