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:
@@ -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)
|
||||
KisaniRingView(
|
||||
outerProgress: outerRingProgress,
|
||||
innerProgress: innerRingProgressWidget,
|
||||
innerColor: innerRingColor
|
||||
)
|
||||
.accessibilityLabel("Backup progress")
|
||||
.accessibilityValue("\(Int(innerRingProgress * 100)) percent")
|
||||
.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 {
|
||||
switch widgetState {
|
||||
case .safe:
|
||||
VStack(spacing: 8) {
|
||||
ringCheckmark
|
||||
Text("ALL PHOTOS SAFE")
|
||||
Text("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)
|
||||
}
|
||||
.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..<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)
|
||||
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 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))
|
||||
}
|
||||
|
||||
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))%" }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user