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 { private var ringHero: some View {
ZStack { ZStack {
KisaniRingView( KisaniRingView(
outerProgress: outerRingProgress, outerProgress: pageOuterProgress,
innerProgress: innerRingProgressWidget, outerColor: pageOuterColor,
innerColor: innerRingColor innerProgress: pageInnerProgress,
innerColor: pageInnerColor
) )
.accessibilityLabel("Backup progress") .accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(innerRingProgressWidget * 100)) percent") .accessibilityValue("\(Int(pageInnerProgress * 100)) percent")
VStack(spacing: 4) { VStack(spacing: 4) {
ringMainView ringMainView
@@ -166,11 +167,41 @@ struct BackupView: View {
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) .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 @ViewBuilder
private var ringMainView: some View { 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: case .safe:
VStack(spacing: 6) { VStack(spacing: 6) {
ringCheckmark ringCheckmark
@@ -221,7 +252,7 @@ struct BackupView: View {
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75) .tracking(2).opacity(0.75)
} }
} } } // end else / switch widgetState
} }
private var ringCheckmark: some View { private var ringCheckmark: some View {
@@ -495,27 +526,8 @@ struct BackupView: View {
@ViewBuilder @ViewBuilder
private var heroView: some View { private var heroView: some View {
switch ringPage { if ringPage == 2 {
case 1: // Cleanup page no ring
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:
HStack(alignment: .lastTextBaseline, spacing: 6) { HStack(alignment: .lastTextBaseline, spacing: 6) {
Text(String(format: "%.1f", totalFreeableGB)) Text(String(format: "%.1f", totalFreeableGB))
.font(.system(size: 64, weight: .ultraLight)) .font(.system(size: 64, weight: .ultraLight))
@@ -531,7 +543,8 @@ struct BackupView: View {
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8) .padding(.bottom, 8)
.gesture(pageSwitchGesture) .gesture(pageSwitchGesture)
default: } else {
// Pages 0 and 1 share the same circle arc values switch in-place
circleSection circleSection
} }
} }
@@ -645,7 +658,7 @@ struct BackupView: View {
private var circleSection: some View { private var circleSection: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
// Ripple expands outward on backup trigger // Ripple expands outward on backup trigger (page 0 only)
Circle() Circle()
.stroke(AppTheme.positive, lineWidth: 1.5) .stroke(AppTheme.positive, lineWidth: 1.5)
.frame(width: 288, height: 288) .frame(width: 288, height: 288)
@@ -656,8 +669,10 @@ struct BackupView: View {
ringHero ringHero
} }
.gesture(pullGesture) .gesture(pullGesture)
.simultaneousGesture(pageSwitchGesture)
.padding(.bottom, 4) .padding(.bottom, 4)
if ringPage == 0 {
cascadingArrows cascadingArrows
.frame(height: 16) .frame(height: 16)
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -665,13 +680,19 @@ struct BackupView: View {
circleStatusRow circleStatusRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20) .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) { .overlay(alignment: .top) {
if ringPage == 0 {
pullZone pullZone
.offset(y: -max(0, pullProgress * 0.52)) .offset(y: -max(0, pullProgress * 0.52))
} }
} }
}
private var pullZone: some View { private var pullZone: some View {
let pct = min(pullProgress / 58, 1.0) let pct = min(pullProgress / 58, 1.0)
@@ -1012,20 +1033,20 @@ struct BackupView: View {
private struct KisaniRingView: View { private struct KisaniRingView: View {
let outerProgress: CGFloat let outerProgress: CGFloat
let outerColor: Color
let innerProgress: CGFloat let innerProgress: CGFloat
let innerColor: Color let innerColor: Color
private let track = Color(red: 0.922, green: 0.922, blue: 0.922) // #ebebeb 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 { var body: some View {
ZStack { ZStack {
// Outer track // Outer track
Circle().stroke(track, lineWidth: 1).padding(10) 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) Circle().trim(from: 0, to: outerProgress)
.stroke(nasArc, style: StrokeStyle(lineWidth: 1, lineCap: .round)) .stroke(outerColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
.shadow(color: nasArc.opacity(0.55), radius: 5, x: 0, y: 0) .shadow(color: outerColor.opacity(0.55), radius: 5, x: 0, y: 0)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.padding(10) .padding(10)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress)