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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user