Lock circle during page swipes — unified ring for pages 0 and 1

Pages 0 (backup) and 1 (storage) now share a single KisaniRingView
instance. Arc values (progress, color) swap in-place via computed
properties so the ring never re-mounts or shifts position during
horizontal swipes. Page 2 (cleanup) retains its own hero layout.
Pull gesture and page-switch gesture coexist via simultaneousGesture.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-21 11:31:01 +03:00
parent 1d7cf63409
commit eb028d32b6

View File

@@ -149,12 +149,13 @@ struct BackupView: View {
private var ringHero: some View {
ZStack {
KisaniRingView(
outerProgress: outerRingProgress,
innerProgress: innerRingProgressWidget,
innerColor: innerRingColor
outerProgress: pageOuterProgress,
outerColor: pageOuterColor,
innerProgress: pageInnerProgress,
innerColor: pageInnerColor
)
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(innerRingProgressWidget * 100)) percent")
.accessibilityValue("\(Int(pageInnerProgress * 100)) percent")
VStack(spacing: 4) {
ringMainView
@@ -166,11 +167,41 @@ struct BackupView: View {
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
private var ringContentID: String { String(describing: widgetState) }
private var ringContentID: String { "\(ringPage)-\(String(describing: widgetState))" }
// Arc values routed per page so the ring stays physically fixed
private var pageOuterProgress: CGFloat {
ringPage == 1 ? usedFraction : outerRingProgress
}
private var pageOuterColor: Color {
ringPage == 1
? Color(red: 1.0, green: 0.58, blue: 0.0)
: Color(red: 0.42, green: 0.52, blue: 0.68)
}
private var pageInnerProgress: CGFloat {
ringPage == 1 ? safeFraction : innerRingProgressWidget
}
private var pageInnerColor: Color {
ringPage == 1 ? AppTheme.positive : innerRingColor
}
@ViewBuilder
private var ringMainView: some View {
switch widgetState {
if ringPage == 1 {
VStack(spacing: 6) {
Image(systemName: "iphone")
.font(.system(size: 14, weight: .ultraLight))
.foregroundStyle(AppTheme.inkTertiary)
Text("\(Int(usedFraction * 100))%")
.font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0))
.kerning(-2)
Text("STORAGE USED")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
} else { switch widgetState {
case .safe:
VStack(spacing: 6) {
ringCheckmark
@@ -221,7 +252,7 @@ struct BackupView: View {
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
}
} } // end else / switch widgetState
}
private var ringCheckmark: some View {
@@ -495,27 +526,8 @@ struct BackupView: View {
@ViewBuilder
private var heroView: some View {
switch ringPage {
case 1:
ZStack {
StorageRingView(usedProgress: usedFraction, safeProgress: safeFraction)
VStack(spacing: 6) {
Image(systemName: "iphone")
.font(.system(size: 14, weight: .ultraLight))
.foregroundStyle(AppTheme.inkTertiary)
Text("\(Int(usedFraction * 100))%")
.font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0))
.kerning(-2)
Text("STORAGE USED")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
}
.gesture(pageSwitchGesture)
.padding(.bottom, 8)
case 2:
if ringPage == 2 {
// Cleanup page no ring
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text(String(format: "%.1f", totalFreeableGB))
.font(.system(size: 64, weight: .ultraLight))
@@ -531,7 +543,8 @@ struct BackupView: View {
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.gesture(pageSwitchGesture)
default:
} else {
// Pages 0 and 1 share the same circle arc values switch in-place
circleSection
}
}
@@ -645,7 +658,7 @@ struct BackupView: View {
private var circleSection: some View {
VStack(spacing: 0) {
ZStack {
// Ripple expands outward on backup trigger
// Ripple expands outward on backup trigger (page 0 only)
Circle()
.stroke(AppTheme.positive, lineWidth: 1.5)
.frame(width: 288, height: 288)
@@ -656,8 +669,10 @@ struct BackupView: View {
ringHero
}
.gesture(pullGesture)
.simultaneousGesture(pageSwitchGesture)
.padding(.bottom, 4)
if ringPage == 0 {
cascadingArrows
.frame(height: 16)
.padding(.bottom, 8)
@@ -665,13 +680,19 @@ struct BackupView: View {
circleStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
} else {
// Match page 0 spacing so the ring stays at the same Y
Color.clear.frame(height: 60)
}
// Pull zone floats above the ring without affecting its layout position
}
// Pull zone floats above the ring on page 0 only
.overlay(alignment: .top) {
if ringPage == 0 {
pullZone
.offset(y: -max(0, pullProgress * 0.52))
}
}
}
private var pullZone: some View {
let pct = min(pullProgress / 58, 1.0)
@@ -1012,20 +1033,20 @@ struct BackupView: View {
private struct KisaniRingView: View {
let outerProgress: CGFloat
let outerColor: Color
let innerProgress: CGFloat
let innerColor: Color
private let track = Color(red: 0.922, green: 0.922, blue: 0.922) // #ebebeb
private let nasArc = Color(red: 0.42, green: 0.52, blue: 0.68)
var body: some View {
ZStack {
// Outer track
Circle().stroke(track, lineWidth: 1).padding(10)
// Outer NAS progress (blue-slate + glow)
// Outer arc color is caller-supplied (NAS slate or storage orange)
Circle().trim(from: 0, to: outerProgress)
.stroke(nasArc, style: StrokeStyle(lineWidth: 1, lineCap: .round))
.shadow(color: nasArc.opacity(0.55), radius: 5, x: 0, y: 0)
.stroke(outerColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
.shadow(color: outerColor.opacity(0.55), radius: 5, x: 0, y: 0)
.rotationEffect(.degrees(-90))
.padding(10)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress)