From da6f1926d2aff359b148bbf8f2ba3c594b7d7002 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Wed, 20 May 2026 19:15:42 +0300 Subject: [PATCH] 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 Co-Authored-By: Sentry --- Features/Backup/BackupView.swift | 611 +++++++++++++++---------------- 1 file changed, 302 insertions(+), 309 deletions(-) diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 2af2012..cbb4cc9 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -14,7 +14,6 @@ struct BackupView: View { @State private var showDestinationPicker = false @State private var pickerPath: String = "/" @State private var ringPage = 0 - @State private var ctaFlashGreen = false private let ringPageCount = 3 private let ringPageNames = ["BACKUP", "IPHONE STORAGE", "CLEANUP"] @@ -22,6 +21,10 @@ struct BackupView: View { @State private var deviceTotalGB: Double = 0 @State private var deviceFreeGB: Double = 0 @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. // 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 if status == .completed { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } Task { - try? await Task.sleep(nanoseconds: 2_500_000_000) - withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false } - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(nanoseconds: 1_000_000_000) 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: — Ring hero + // MARK: — Ring hero (used inside circleSection — no gesture here) private var ringHero: some View { ZStack { - KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress) - .accessibilityLabel("Backup progress") - .accessibilityValue("\(Int(innerRingProgress * 100)) percent") + KisaniRingView( + outerProgress: outerRingProgress, + innerProgress: innerRingProgressWidget, + innerColor: innerRingColor + ) + .accessibilityLabel("Backup progress") + .accessibilityValue("\(Int(innerRingProgressWidget * 100)) percent") VStack(spacing: 4) { ringMainView @@ -177,84 +177,35 @@ struct BackupView: View { } .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID) } - .gesture(pageSwitchGesture) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } - private var pageSwitchGesture: some Gesture { - 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 - } - } - } - } + private var ringContentID: String { String(describing: widgetState) } - // 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 private var ringMainView: some View { - switch resolvedStatus { - case .idle, .cancelled: - if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { - VStack(spacing: 8) { - 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: 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) - } + switch widgetState { + case .safe: + VStack(spacing: 8) { + ringCheckmark + Text("PHOTOS SAFE") + .font(.system(size: 8, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.positive.opacity(0.75)) + .tracking(2) } - case .preparing: + case .checking: VStack(spacing: 10) { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) - Text("SCANNING LIBRARY") + Text("CHECKING") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } - case .running, .paused: + case .running: VStack(spacing: 5) { Text(percentText) .font(.system(size: 46, weight: .ultraLight)) @@ -274,72 +225,17 @@ struct BackupView: View { } } - case .completed: - VStack(spacing: 8) { - 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: 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.. 0 { - Text("\(coordinator.pendingAtDisconnect) QUEUED") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2).opacity(0.75) + default: // offline, pending, paused + 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" : "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) } - 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 } - // 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) + private var innerRingProgressWidget: CGFloat { + switch widgetState { + case .safe: return 1.0 + case .running: return CGFloat(engine.job.progress) + default: return CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal)) } - 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))%" } @@ -450,77 +337,6 @@ struct BackupView: View { .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 { VStack(spacing: 2) { Text(value) @@ -670,74 +486,6 @@ struct BackupView: View { .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() { guard let conn = store.savedConnection else { return } 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" } + // 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 private var deviceUsedGB: Double { max(0, deviceTotalGB - deviceFreeGB) } @@ -768,29 +771,17 @@ struct BackupView: View { @ViewBuilder private var page0Content: some View { - ringHero.padding(.bottom, 12) - checkStatusRow - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 20) + circleSection statsStrip .padding(.horizontal, AppTheme.hPad) .transition(.opacity.combined(with: .scale(scale: 0.97))) - Spacer(minLength: 20).frame(maxHeight: 48) + Spacer(minLength: 16).frame(maxHeight: 32) nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) destinationRow .padding(.horizontal, AppTheme.hPad) - Spacer(minLength: 20).frame(maxHeight: 32) - 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) + Spacer(minLength: 8).frame(maxHeight: 20) } @ViewBuilder @@ -1008,6 +999,7 @@ struct BackupView: View { private struct KisaniRingView: View { let outerProgress: CGFloat let innerProgress: CGFloat + let innerColor: Color 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) @@ -1029,12 +1021,13 @@ private struct KisaniRingView: View { // Inner track Circle().stroke(track, lineWidth: 1.5).padding(38) - // Inner progress (green) + // Inner progress (color-variable) 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)) .padding(38) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) + .animation(.easeInOut(duration: 0.5), value: innerColor) } .frame(width: 240, height: 240) }