Rewrite BackupView circle widget with 6-state pull gesture UX

- Replace paginated ring center with full WidgetState machine (offline/checking/pending/running/safe/paused)
- Pull-down gesture on circle triggers backup with haptic + ripple animation
- Cascading TimelineView arrows, status dot pulse, live sub-label
- Add innerColor param to KisaniRingView for per-state color transitions
- Add pageSwitchGesture for horizontal swipe on pages 1 & 2
- Remove all action buttons; pull gesture is the sole backup trigger

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-20 19:15:42 +03:00
parent f59f79bad6
commit da6f1926d2

View File

@@ -14,7 +14,6 @@ struct BackupView: View {
@State private var showDestinationPicker = false @State private var showDestinationPicker = false
@State private var pickerPath: String = "/" @State private var pickerPath: String = "/"
@State private var ringPage = 0 @State private var ringPage = 0
@State private var ctaFlashGreen = false
private let ringPageCount = 3 private let ringPageCount = 3
private let ringPageNames = ["BACKUP", "IPHONE STORAGE", "CLEANUP"] private let ringPageNames = ["BACKUP", "IPHONE STORAGE", "CLEANUP"]
@@ -22,6 +21,10 @@ struct BackupView: View {
@State private var deviceTotalGB: Double = 0 @State private var deviceTotalGB: Double = 0
@State private var deviceFreeGB: Double = 0 @State private var deviceFreeGB: Double = 0
@State private var showCleanConfirm = false @State private var showCleanConfirm = false
@State private var pullProgress: CGFloat = 0
@State private var rippleScale: CGFloat = 0.88
@State private var rippleOpacity: Double = 0
@State private var pulseDotOpacity: Double = 1.0
// Counts are the source of truth for final display state. // Counts are the source of truth for final display state.
// If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed. // If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed.
@@ -144,31 +147,28 @@ struct BackupView: View {
} }
.onChange(of: engine.job.status) { status in .onChange(of: engine.job.status) { status in
if status == .completed { if status == .completed {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
Task { Task {
try? await Task.sleep(nanoseconds: 2_500_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
try? await Task.sleep(nanoseconds: 500_000_000)
statusService.refresh(force: true) statusService.refresh(force: true)
} }
} }
// .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete.
// It waits for refreshAfterBackup's writeManifest to finish before reconciling, then
// calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above.
// Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest.
} }
} }
// MARK: Logo mark (drawn in code no asset dependency) // MARK: Logo mark (drawn in code no asset dependency)
// MARK: Ring hero // MARK: Ring hero (used inside circleSection no gesture here)
private var ringHero: some View { private var ringHero: some View {
ZStack { ZStack {
KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress) KisaniRingView(
outerProgress: outerRingProgress,
innerProgress: innerRingProgressWidget,
innerColor: innerRingColor
)
.accessibilityLabel("Backup progress") .accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(innerRingProgress * 100)) percent") .accessibilityValue("\(Int(innerRingProgressWidget * 100)) percent")
VStack(spacing: 4) { VStack(spacing: 4) {
ringMainView ringMainView
@@ -177,84 +177,35 @@ struct BackupView: View {
} }
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID) .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
} }
.gesture(pageSwitchGesture)
.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 pageSwitchGesture: some Gesture { private var ringContentID: String { String(describing: widgetState) }
DragGesture(minimumDistance: 20)
.onEnded { value in
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
if value.translation.width < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
}
}
// ID that changes on both page swipe and status transition drives the ring animation
private var ringContentID: String {
let enginePart: String
switch resolvedStatus {
case .idle, .cancelled: enginePart = "idle"
case .preparing: enginePart = "prep"
case .running: enginePart = "run"
case .paused: enginePart = "pause"
case .completed: enginePart = "done"
case .failed: enginePart = "fail"
}
// Include coordinator phase so ring animates on checking/disconnected transitions
let coordSuffix: String
switch coordinator.phase {
case .checking: coordSuffix = "_chk"
case .disconnected: coordSuffix = "_disc"
case .autoBackingUp: coordSuffix = "_auto"
case .idle: coordSuffix = ""
}
return "\(enginePart)\(coordSuffix)"
}
// Page 0 content changes based on backup state
@ViewBuilder @ViewBuilder
private var ringMainView: some View { private var ringMainView: some View {
switch resolvedStatus { switch widgetState {
case .idle, .cancelled: case .safe:
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
VStack(spacing: 8) { VStack(spacing: 8) {
ringCheckmark ringCheckmark
Text("ALL PHOTOS SAFE") Text("PHOTOS SAFE")
.font(.system(size: 8, weight: .regular, design: .monospaced)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.positive.opacity(0.75))
.tracking(2).opacity(0.75) .tracking(2)
}
} else {
VStack(spacing: 6) {
Text("\(statusService.snapshot.needBackup)")
.font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.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)
}
} }
case .preparing: case .checking:
VStack(spacing: 10) { VStack(spacing: 10) {
ProgressView() ProgressView()
.scaleEffect(0.85) .scaleEffect(0.85)
.tint(AppTheme.inkTertiary) .tint(AppTheme.inkTertiary)
Text("SCANNING LIBRARY") Text("CHECKING")
.font(.system(size: 8, weight: .regular, design: .monospaced)) .font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75) .tracking(2).opacity(0.75)
} }
case .running, .paused: case .running:
VStack(spacing: 5) { VStack(spacing: 5) {
Text(percentText) Text(percentText)
.font(.system(size: 46, weight: .ultraLight)) .font(.system(size: 46, weight: .ultraLight))
@@ -274,72 +225,17 @@ struct BackupView: View {
} }
} }
case .completed: default: // offline, pending, paused
VStack(spacing: 8) { VStack(spacing: 6) {
ringCheckmark Text("\(statusService.snapshot.needBackup)")
Text("ALL DONE") .font(.system(size: 46, weight: .ultraLight))
.font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.ink)
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
case .failed:
VStack(spacing: 8) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 26, weight: .light))
.foregroundStyle(AppTheme.destructive)
Text("BACKUP FAILED")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.destructive.opacity(0.8))
.tracking(2).opacity(0.75)
}
}
}
// MARK: Page indicator
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Capsule(style: .continuous)
.fill(i == ringPage ? AppTheme.interactive : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 18 : 5, height: 5)
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: ringPage)
}
}
}
// MARK: Coordinator ring states (shown when engine is idle)
@ViewBuilder
private var checkingRingView: some View {
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("CHECKING")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
}
@ViewBuilder
private var disconnectedRingView: some View {
VStack(spacing: 8) {
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: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkSecondary)
.tracking(2).opacity(0.75)
if coordinator.pendingAtDisconnect > 0 {
Text("\(coordinator.pendingAtDisconnect) QUEUED")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
.contentTransition(.numericText()) .contentTransition(.numericText())
.kerning(-2)
Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS" : "NEED BACKUP")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
} }
} }
} }
@@ -361,21 +257,12 @@ struct BackupView: View {
return min(nas / (nas + phone), 1) return min(nas / (nas + phone), 1)
} }
private var innerRingProgress: CGFloat { private var innerRingProgressWidget: CGFloat {
CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal)) switch widgetState {
case .safe: return 1.0
case .running: return CGFloat(engine.job.progress)
default: return 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 }
// Disconnected with pending items: dim orange pulse
if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35)
}
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
return AppTheme.positive
}
return AppTheme.inkQuaternary.opacity(0.55)
} }
private var percentText: String { "\(Int(engine.job.progress * 100))%" } private var percentText: String { "\(Int(engine.job.progress * 100))%" }
@@ -450,77 +337,6 @@ struct BackupView: View {
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} }
private var checkStatusRow: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
if statusService.isRefreshing {
ProgressView()
.scaleEffect(0.5)
.tint(AppTheme.inkQuaternary)
Text("Checking…")
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
} else if let checked = statusService.snapshot.lastCheckedAt {
Image(systemName: statusService.snapshot.connectionState == .offline
? "wifi.slash" : "checkmark.circle")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(statusService.snapshot.connectionState == .offline
? .orange : AppTheme.inkQuaternary)
Text(checked, style: .relative)
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
+ Text(" ago")
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
}
Spacer()
Button {
statusService.refresh(force: true)
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.frame(height: 16)
HStack(spacing: 4) {
Image(systemName: syncStatusIcon)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(syncStatusColor)
Text(syncStatusLabel)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(syncStatusColor)
}
.animation(.easeInOut(duration: 0.2), value: syncStatusLabel)
}
}
private var syncStatusIcon: String {
if engine.job.status == .preparing { return "magnifyingglass" }
if engine.job.status.isActive { return "arrow.triangle.2.circlepath" }
if engine.job.status == .paused { return "pause.fill" }
if statusService.isRefreshing || coordinator.phase == .checking { return "magnifyingglass" }
if statusService.snapshot.connectionState == .offline { return "wifi.slash" }
return "stop.fill"
}
private var syncStatusColor: Color {
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if engine.job.status == .paused { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if statusService.snapshot.connectionState == .offline { return AppTheme.destructive }
return AppTheme.inkQuaternary
}
private var syncStatusLabel: String {
if engine.job.status == .preparing { return "Checking" }
if engine.job.status.isActive { return "Running" }
if engine.job.status == .paused { return "Paused" }
if statusService.isRefreshing || coordinator.phase == .checking { return "Checking" }
if statusService.snapshot.connectionState == .offline { return "Offline" }
return "Stopped"
}
private func compactStat(_ value: String, label: String, color: Color) -> some View { private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text(value) Text(value)
@@ -670,74 +486,6 @@ struct BackupView: View {
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
} }
// MARK: Photos access nudge
private var photosAccessRow: some View {
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack(spacing: 10) {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
.frame(width: 20)
Text("Grant photo library access to enable backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(AppTheme.destructive.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
// MARK: Action button
private var actionButton: some View {
Group {
switch resolvedStatus {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
}
.buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 50)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
}
private func startBackup() { private func startBackup() {
guard let conn = store.savedConnection else { return } guard let conn = store.savedConnection else { return }
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
@@ -747,6 +495,261 @@ 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: Widget state
private enum WidgetState: Equatable {
case offline, checking, pending, running, safe, paused
}
private var widgetState: WidgetState {
if lanMonitor.nasReachable == false { return .offline }
switch resolvedStatus {
case .paused: return .paused
case .running, .preparing: return .running
case .completed: return .safe
default: break
}
if statusService.isRefreshing || coordinator.phase == .checking { return .checking }
if statusService.snapshot.needBackup == 0, statusService.snapshot.phoneTotal > 0 { return .safe }
return .pending
}
private var innerRingColor: Color {
switch widgetState {
case .offline: return AppTheme.destructive
case .checking: return Color(red: 0.58, green: 0.64, blue: 0.73)
case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0)
case .running: return Color(red: 1.0, green: 0.62, blue: 0.0)
case .safe: return AppTheme.positive
case .paused: return Color(red: 0.82, green: 0.82, blue: 0.82)
}
}
private var statusLabelText: String {
switch widgetState {
case .offline: return "NAS unreachable"
case .checking: return "Checking…"
case .pending: return "Ready to back up"
case .running: return "Backing up"
case .safe: return "All safe"
case .paused: return "Backup paused"
}
}
private var statusSubText: String {
switch widgetState {
case .offline:
if let date = statusService.snapshot.lastCheckedAt {
let s = Int(-date.timeIntervalSinceNow)
return s < 60 ? "last seen \(s) sec ago" : "last seen \(s / 60) min ago"
}
return "unable to connect"
case .checking: return "reconciling with NAS"
case .pending: return "pull circle to start"
case .running:
if let eta = engine.job.eta { return "\(etaString(eta)) remaining" }
return "uploading…"
case .safe: return "synced with NAS"
case .paused: return "pull circle to resume"
}
}
private var dotColor: Color {
switch widgetState {
case .offline: return AppTheme.destructive
case .checking: return AppTheme.inkTertiary
case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0)
case .running: return Color(red: 1.0, green: 0.62, blue: 0.0)
case .safe: return AppTheme.positive
case .paused: return Color(red: 1.0, green: 0.62, blue: 0.0)
}
}
private var dotShouldPulse: Bool {
widgetState == .offline || widgetState == .pending || widgetState == .paused
}
private var showArrows: Bool {
widgetState == .offline || widgetState == .pending || widgetState == .paused
}
private var arrowsColor: Color {
widgetState == .offline ? AppTheme.destructive : Color(red: 1.0, green: 0.62, blue: 0.0)
}
// MARK: Circle section
private var circleSection: some View {
VStack(spacing: 0) {
pullZone
ZStack {
// Ripple expands outward on backup trigger
Circle()
.stroke(AppTheme.positive, lineWidth: 1.5)
.frame(width: 258, height: 258)
.scaleEffect(rippleScale)
.opacity(rippleOpacity)
.allowsHitTesting(false)
ringHero
}
.offset(y: min(pullProgress * 0.26, 14))
.animation(.spring(response: 0.38, dampingFraction: 0.8), value: pullProgress)
.gesture(pullGesture)
.padding(.bottom, 4)
cascadingArrows
.frame(height: 16)
.padding(.bottom, 8)
circleStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
}
}
private var pullZone: some View {
let pct = min(pullProgress / 58, 1.0)
let ready = pullProgress >= 58
return VStack(spacing: 3) {
Text("")
.font(.system(size: 13))
.foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary)
.rotationEffect(.degrees(ready ? 180 : pct * 160))
.opacity(min(pct * 2.5, 1))
Text(ready ? "RELEASE TO BACK UP" : "PULL TO BACK UP")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary)
.tracking(2)
.opacity(min(pct * 3, 1))
}
.padding(.bottom, 6)
.frame(maxWidth: .infinity, maxHeight: max(0, pullProgress * 0.52), alignment: .bottom)
.clipped()
}
private var cascadingArrows: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !showArrows)) { tl in
let t = tl.date.timeIntervalSince1970
HStack(spacing: 3) {
ForEach(0..<3, id: \.self) { i in
let phase = ((t.truncatingRemainder(dividingBy: 1.6) - Double(i) * 0.22) + 1.6)
.truncatingRemainder(dividingBy: 1.6) / 1.6
Text("")
.font(.system(size: 10))
.foregroundStyle(arrowsColor)
.opacity(arrowFlowOpacity(phase))
.offset(y: arrowFlowOffset(phase))
}
}
}
.opacity(showArrows ? 1 : 0)
.animation(.easeInOut(duration: 0.4), value: showArrows)
}
private var circleStatusRow: some View {
VStack(spacing: 4) {
HStack(spacing: 6) {
if widgetState == .checking || widgetState == .running {
ProgressView()
.scaleEffect(0.55)
.tint(AppTheme.inkTertiary)
.frame(width: 10, height: 10)
} else {
Circle()
.fill(dotColor)
.frame(width: 6, height: 6)
.opacity(pulseDotOpacity)
.onAppear {
if dotShouldPulse {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
pulseDotOpacity = 0.3
}
}
}
.onChange(of: dotShouldPulse) { pulse in
if pulse {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
pulseDotOpacity = 0.3
}
} else {
withAnimation { pulseDotOpacity = 1.0 }
}
}
}
Text(statusLabelText)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(widgetState == .offline ? AppTheme.destructive : AppTheme.ink)
}
Text(statusSubText)
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(0.08)
}
.multilineTextAlignment(.center)
.id(widgetState)
.transition(.opacity.combined(with: .offset(y: 8)))
.animation(.easeInOut(duration: 0.35), value: widgetState)
}
// MARK: Page switch gesture (used on pages 1 & 2 hero areas)
private var pageSwitchGesture: some Gesture {
DragGesture(minimumDistance: 30)
.onEnded { value in
let dx = value.translation.width
guard abs(dx) > abs(value.translation.height) else { return }
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
if dx < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
}
}
// MARK: Pull gesture
private var pullGesture: some Gesture {
DragGesture(minimumDistance: 8)
.onChanged { value in
guard value.translation.height > 0 else { return }
let dy = value.translation.height
if pullProgress < 58 && dy >= 58 {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
pullProgress = dy
}
.onEnded { value in
let fired = value.translation.height >= 58
withAnimation(.spring(response: 0.38, dampingFraction: 0.85)) { pullProgress = 0 }
if fired {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
triggerBackupViaPull()
}
}
}
private func triggerBackupViaPull() {
rippleScale = 0.88; rippleOpacity = 0.6
withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 }
if engine.job.status == .paused { engine.resume() } else { startBackup() }
}
private func arrowFlowOpacity(_ phase: Double) -> Double {
if phase < 0.4 { return phase / 0.4 * 0.7 }
if phase < 0.8 { return (0.8 - phase) / 0.4 * 0.7 }
return 0
}
private func arrowFlowOffset(_ phase: Double) -> CGFloat {
if phase < 0.4 { return CGFloat(-3 + phase / 0.4 * 3) }
if phase < 0.8 { return CGFloat((phase - 0.4) / 0.4 * 3) }
return 3
}
// MARK: Device storage // MARK: Device storage
private var deviceUsedGB: Double { max(0, deviceTotalGB - deviceFreeGB) } private var deviceUsedGB: Double { max(0, deviceTotalGB - deviceFreeGB) }
@@ -768,29 +771,17 @@ struct BackupView: View {
@ViewBuilder @ViewBuilder
private var page0Content: some View { private var page0Content: some View {
ringHero.padding(.bottom, 12) circleSection
checkStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
statsStrip statsStrip
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97))) .transition(.opacity.combined(with: .scale(scale: 0.97)))
Spacer(minLength: 20).frame(maxHeight: 48) Spacer(minLength: 16).frame(maxHeight: 32)
nasStatusRow nasStatusRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12) .padding(.bottom, 12)
destinationRow destinationRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
Spacer(minLength: 20).frame(maxHeight: 32) Spacer(minLength: 8).frame(maxHeight: 20)
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
} }
@ViewBuilder @ViewBuilder
@@ -1008,6 +999,7 @@ struct BackupView: View {
private struct KisaniRingView: View { private struct KisaniRingView: View {
let outerProgress: CGFloat let outerProgress: CGFloat
let innerProgress: CGFloat let innerProgress: CGFloat
let innerColor: Color
private let track = Color(red: 0.76, green: 0.76, blue: 0.76) private let track = Color(red: 0.76, green: 0.76, blue: 0.76)
private let nasArc = Color(red: 0.42, green: 0.52, blue: 0.68) private let nasArc = Color(red: 0.42, green: 0.52, blue: 0.68)
@@ -1029,12 +1021,13 @@ private struct KisaniRingView: View {
// Inner track // Inner track
Circle().stroke(track, lineWidth: 1.5).padding(38) Circle().stroke(track, lineWidth: 1.5).padding(38)
// Inner progress (green) // Inner progress (color-variable)
Circle().trim(from: 0, to: innerProgress) Circle().trim(from: 0, to: innerProgress)
.stroke(AppTheme.positive, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) .stroke(innerColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.padding(38) .padding(38)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
.animation(.easeInOut(duration: 0.5), value: innerColor)
} }
.frame(width: 240, height: 240) .frame(width: 240, height: 240)
} }