From 97d2530f9998858688ef3b82a782a5d2f3ec70e1 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Tue, 6 Jan 2026 16:40:00 -0500 Subject: [PATCH] UI improvements --- Localizable.xcstrings | 7 +- .../ESP32 OTA/BLE/ESP32BLEOTASheet.swift | 241 +++++++++++------- .../ESP32 OTA/ESP32OTAIntroSheet.swift | 47 +--- .../ESP32 OTA/WiFi/ESP32WifiOTASheet.swift | 217 ++++++++++------ .../Helpers/CircularProgressView.swift | 106 +++++--- 5 files changed, 372 insertions(+), 246 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 239e32b4..22d227ee 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -28568,10 +28568,6 @@ "comment" : "A button label that says \"Reboot & Start Update\".", "isCommentAutoGenerated" : true }, - "Reboot into Wifi OTA Update Mode" : { - "comment" : "A button label that prompts the user to reboot their device into OTA update mode.", - "isCommentAutoGenerated" : true - }, "Reboot node?" : { "localizations" : { "de" : { @@ -29869,6 +29865,9 @@ "Retry" : { "comment" : "A button label that says \"Retry\".", "isCommentAutoGenerated" : true + }, + "Retry Update" : { + }, "Retrying (attempt %@)" : { diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift index e9354d50..9f46d0cd 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift @@ -1,13 +1,12 @@ // -// ESP3BLEOTASheet.swift +// ESP32BLEOTASheet.swift // Meshtastic // // Created by jake on 12/20/25. // -import Foundation import SwiftUI -import OSLog +import os import CoreBluetooth import CryptoKit @@ -16,21 +15,21 @@ struct ESP32BLEOTASheet: View { @Environment(\.dismiss) var dismiss @Environment(\.managedObjectContext) var context @StateObject var ota = ESP32BLEOTAViewModel() - @State var rebootNeeded = true - // The stuff were updating, and the place we're updating it to + + @State var rebootSuccessful = false + @State var inRetryWorkflow = false + + // The stuff we're updating, and the place we're updating it to let binFileURL: URL + + // To dismiss the intro sheet when complete. + var onUpdateComplete: (() -> Void)? = nil + @State var peripheral: CBPeripheral? var body: some View { NavigationStack { List { - Section { - VStack { - Text("Please do not leave this screen until this process is complete.") - .multilineTextAlignment(.center) - }.listRowBackground(Color.clear) - } - Section { VStack(alignment: .leading) { Text("Firmware File").font(.caption).foregroundColor(.secondary) @@ -39,12 +38,15 @@ struct ESP32BLEOTASheet: View { VStack(alignment: .leading) { Text("BLE Device").font(.caption).foregroundColor(.secondary) if let peripheral { - Text("\(peripheral.name, default: "Unknown")").font(.caption) - Text("\(peripheral.identifier, default: "Unknown")").font(.caption) + Text("\(peripheral.name ?? "Unknown")").font(.caption) + Text("\(peripheral.identifier.uuidString)").font(.caption) } else { Text("No device connected. Will use first discovered device.").font(.caption) } } + } header: { + Text("Please do not leave this screen until this process is complete.") + .multilineTextAlignment(.center) } footer: { Text("Please be sure this is correct before proceeding.") } @@ -52,115 +54,162 @@ struct ESP32BLEOTASheet: View { Section { HStack(alignment: .center) { Spacer() - // Progress is 0.0 to 1.0 - CircularProgressView(progress: ota.transferProgress, isIndeterminate: (ota.otaStatus == .preparing), size: 225.0, subtitleText: ota.otaStatus.rawValue) - .frame(minHeight: 250.0) + + // MARK: - Progress View + CircularProgressView( + progress: ota.transferProgress, + isIndeterminate: (ota.otaStatus == .preparing), + isError: (ota.otaStatus == .error), + size: 225.0, + // If error, show nil (triangle only). Text is shown below. + subtitleText: (ota.otaStatus == .error) ? nil : ota.otaStatus.rawValue + ) + .frame(minHeight: 250.0) + Spacer() - }.listRowBackground(Color.clear) - VStack { - if ota.otaStatus == .idle { - beginBLEProcessButton() - } else if ota.otaStatus == .error { - retryButton() - } else { - Text("\(ota.statusMessage)") + } + .listRowBackground(Color.clear) + + VStack(spacing: 12) { + if ota.otaStatus != .idle { + Text(ota.statusMessage) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .font(.headline) + .foregroundStyle(ota.otaStatus == .error ? .red : .primary) } - }.listRowBackground(Color.clear) - }.listRowSeparator(.hidden) - }.listSectionSpacing(.compact) + + switch ota.otaStatus { + case .idle: + beginBLEProcessButton() + + case .error: + retryButton() + + default: + EmptyView() + } + } + .listRowBackground(Color.clear) + } + .listRowSeparator(.hidden) + } + .listSectionSpacing(.compact) .navigationTitle("ESP32 BLE Updater") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { - dismiss() - }.disabled(![.idle, .completed, .error].contains(ota.otaStatus)) + if let onUpdateComplete, ota.otaStatus == .completed { + onUpdateComplete() + } else { + dismiss() + } + } + .disabled(![.idle, .completed, .error].contains(ota.otaStatus)) } } - }.task { + } + .task { + // Attempt to grab peripheral from current BLE connection if let connection = accessoryManager.activeConnection?.connection as? BLEConnection { self.peripheral = await connection.peripheral } - }.interactiveDismissDisabled(true) - .textCase(nil) - + } + .interactiveDismissDisabled(true) + .textCase(nil) } + // MARK: - Component Views + @ViewBuilder func retryButton() -> some View { - VStack(spacing: 12) { - Text("Error: \(ota.statusMessage)") - .multilineTextAlignment(.center) - .foregroundStyle(.red) - .font(.headline) + Button { + self.inRetryWorkflow = true + var transaction = Transaction(animation: .none) + transaction.disablesAnimations = true - Button { - var transaction = Transaction(animation: .none) - transaction.disablesAnimations = true - - withTransaction(transaction) { - rebootNeeded = false - ota.retry() - } - } label: { - Label("Retry", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) - .foregroundStyle(.white) + withTransaction(transaction) { + // Determine if we need to reboot again (usually no, unless connection was totally lost before reboot) + ota.retry() } - .buttonStyle(.borderedProminent) - .tint(.red) - .controlSize(.large) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + .foregroundStyle(.white) } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) } @ViewBuilder func beginBLEProcessButton() -> some View { Button { - let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) - if let connectedNode, let user = connectedNode.user { - Task { - do { - if rebootNeeded { - let data = try Data(contentsOf: binFileURL) - let sha256Digest = Data(SHA256.hash(data: data)) - - // Send the reboot command to the node via existing mesh protocol - try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaBle, otaHash: sha256Digest) - - // Disconnect app so the ViewModel can grab the new OTA-Mode advertisement - try await accessoryManager.disconnect() - - // disable discovery - accessoryManager.otaInProgress = true - accessoryManager.stopDiscovery() - - // Wait briefly for device to reboot - try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - } - - // Set auto-reconnect - accessoryManager.shouldAutomaticallyConnectToPreferredPeripheral = true - - // Start the OTA process - await ota.startOTA(binURL: binFileURL, desiredPeripheral: peripheral?.identifier) - - // restart discovery - accessoryManager.otaInProgress = false - accessoryManager.startDiscovery() - } catch { - Logger.mesh.error("Reboot Failed") - } - } - } + startBLEProcess() } label: { - Label("Reboot & Start Update", systemImage: "square.and.arrow.down") - .frame(maxWidth: .infinity) - - }.buttonStyle(.bordered) - .controlSize(.large) - .disabled(accessoryManager.activeDeviceNum == nil) + if self.inRetryWorkflow { + Label("Retry Update", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } else { + Label("Reboot & Start Update", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(accessoryManager.activeDeviceNum == nil) + } + + // MARK: - Logic + + private func startBLEProcess() { + // Safe unwrap of required data + guard let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context), + let user = connectedNode.user else { + return + } + + Task { + do { + if !rebootSuccessful { + // 1. Move file reading/hashing to a detached task to avoid blocking Main Thread + let sha256Digest = try await Task.detached(priority: .userInitiated) { + let data = try Data(contentsOf: binFileURL) + let digest = SHA256.hash(data: data) + return Data(digest) + }.value + + // 2. Send the reboot command via existing connection + try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaBle, otaHash: sha256Digest) + rebootSuccessful = true + + // 3. Disconnect app so the ViewModel can grab the new OTA-Mode advertisement + try await accessoryManager.disconnect() + + // 4. Disable discovery to focus on the specific OTA device + accessoryManager.otaInProgress = true + accessoryManager.stopDiscovery() + + // 5. Wait briefly for device to reboot + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + } + + // 6. Set auto-reconnect preference + accessoryManager.shouldAutomaticallyConnectToPreferredPeripheral = true + + // 7. Start the OTA process + await ota.startOTA(binURL: binFileURL, desiredPeripheral: peripheral?.identifier) + + // 8. Cleanup / Restart discovery + accessoryManager.otaInProgress = false + accessoryManager.startDiscovery() + + } catch { + Logger.mesh.error("ESP32 BLE OTA Failed: \(error.localizedDescription)") + // Note: You might want to update `ota.otaStatus` to .error here if the View Model doesn't catch it + } + } } } diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift index 6812d88a..9a480a14 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -138,16 +138,18 @@ struct ESP32OTAIntroSheet: View { #endif }.sheet(isPresented: $showWifiUpdater) { - var theHost: String? = nil - #if DEBUG - if !debugHost.isEmpty { - theHost = debugHost - } - #endif - return ESP32WifiOTASheet(binFileURL: binFileURL, host: theHost) + let theHost: String? = { + #if DEBUG + if !debugHost.isEmpty { + return debugHost + } + #endif + return nil + }() + ESP32WifiOTASheet(binFileURL: binFileURL, host: theHost, onUpdateComplete: { dismiss() }) .environmentObject(accessoryManager) }.sheet(isPresented: $showBLEUpdater) { - ESP32BLEOTASheet(binFileURL: binFileURL) + ESP32BLEOTASheet(binFileURL: binFileURL, onUpdateComplete: { dismiss() }) .environmentObject(accessoryManager) } .navigationTitle("ESP32 Update") @@ -181,32 +183,5 @@ struct ESP32OTAIntroSheet: View { } return .none } - // func beginBLEProcessButton() -> some View { - // Button { - // let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) - // if let connectedNode, let user = connectedNode.user { - // Task { - // do { - // if let host { - // let device = accessoryManager.activeConnection?.device - // try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 2) - // try await accessoryManager.disconnect() - // await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) - // if let device { - // try await Task.sleep(for: .seconds(3)) - // try await accessoryManager.connect(to: device, retries: 5) - // } - // } - // } catch { - // Logger.mesh.error("Reboot Failed") - // } - // } - // } - // } label: { - // Label("Reboot into BLE OTA Update Mode", systemImage: "square.and.arrow.down") - // .frame(maxWidth: .infinity) - // }.buttonStyle(.bordered) - // .controlSize(.large) - // .disabled(accessoryManager.activeDeviceNum == nil) - // } + } diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift index 8ad61112..73c87195 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift @@ -5,9 +5,8 @@ // Created by jake on 12/20/25. // -import Foundation import SwiftUI -import OSLog +import os import CryptoKit struct ESP32WifiOTASheet: View { @@ -16,12 +15,20 @@ struct ESP32WifiOTASheet: View { @Environment(\.managedObjectContext) var context @StateObject var ota = ESP32WifiOTAViewModel() - // The stuff were updating, and the place we're updating it to + // The file we're updating, and the place we're updating it to let binFileURL: URL - @State var host: String? - @State var alreadyRebooted: Bool = false - init(binFileURL: URL, host: String? = nil) { + // IP address of the host (optional) + @State var host: String? + + // To dismiss the intro sheet when complete. + let onUpdateComplete: (() -> Void)? + + @State var alreadyRebooted: Bool = false + @State var inRetryWorkflow = false + + init(binFileURL: URL, host: String? = nil, onUpdateComplete: (() -> Void)? = nil) { + self.onUpdateComplete = onUpdateComplete self.binFileURL = binFileURL self._host = State(initialValue: host) } @@ -29,13 +36,6 @@ struct ESP32WifiOTASheet: View { var body: some View { NavigationStack { List { - Section { - VStack { - Text("Please do not leave this screen until this process is complete.") - .multilineTextAlignment(.center) - }.listRowBackground(Color.clear) - } - Section { VStack(alignment: .leading) { Text("Firmware File").font(.caption).foregroundColor(.secondary) @@ -45,6 +45,9 @@ struct ESP32WifiOTASheet: View { Text("Network Location").font(.caption).foregroundColor(.secondary) Text("\(host ?? "Unknown")").font(.caption) } + } header: { + Text("Please do not leave this screen until this process is complete.") + .multilineTextAlignment(.center) } footer: { Text("Please be sure this is correct before proceeding.") } @@ -52,11 +55,32 @@ struct ESP32WifiOTASheet: View { Section { HStack(alignment: .center) { Spacer() - CircularProgressView(progress: ota.progress, isIndeterminate: (ota.otaState == .preparing), size: 225.0, subtitleText: ota.otaState.rawValue) - .frame(minHeight: 250.0) + + // MARK: - Progress View + CircularProgressView( + progress: ota.progress, + isIndeterminate: (ota.otaState == .preparing), + isError: (ota.otaState == .error), + size: 225.0, + // If error, we show only the triangle (nil). + // The detailed status message is shown below the ring. + subtitleText: (ota.otaState == .error) ? nil : ota.otaState.rawValue + ) + .frame(minHeight: 250.0) + Spacer() - }.listRowBackground(Color.clear) - VStack { + } + .listRowBackground(Color.clear) + + VStack(spacing: 12) { + if ota.otaState != .idle { + Text("\(ota.statusMessage)") + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .font(.headline) + .foregroundStyle(ota.otaState == .error ? .red : .primary) + } + switch ota.otaState { case .idle: beginWifiProcessButton() @@ -65,94 +89,125 @@ struct ESP32WifiOTASheet: View { retryButton() default: - Text("\(ota.statusMessage, default: "")") - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .font(.headline) + EmptyView() } - }.listRowBackground(Color.clear) - }.listRowSeparator(.hidden) - }.listSectionSpacing(.compact) + } + .listRowBackground(Color.clear) + } + .listRowSeparator(.hidden) + } + .listSectionSpacing(.compact) .navigationTitle("ESP32 WiFi Updater") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close" + ToolbarItem(placement: .cancellationAction) { Button("Done") { - dismiss() - }.disabled(![.idle, .completed, .error].contains(ota.otaState)) + if let onUpdateComplete = self.onUpdateComplete, ota.otaState == .completed { + onUpdateComplete() + } else { + dismiss() + } + } + .disabled(![.idle, .completed, .error].contains(ota.otaState)) } } - }.task { + } + .task { + // Attempt to grab host from current TCP connection if available if let connection = accessoryManager.activeConnection?.connection as? TCPConnection { self.host = await connection.host.stringValue } - }.interactiveDismissDisabled(true) - + } + .interactiveDismissDisabled(true) } + // MARK: - Component Views + @ViewBuilder func retryButton() -> some View { - VStack(spacing: 12) { - Text("Error: \(ota.statusMessage)") - .multilineTextAlignment(.center) - .foregroundStyle(.red) - .font(.headline) + Button { + inRetryWorkflow = true - Button { - var transaction = Transaction(animation: .none) - transaction.disablesAnimations = true - - withTransaction(transaction) { - ota.retry() - } - } label: { - Label("Retry", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) - .foregroundStyle(.white) + // Disable animations for the immediate state reset + var transaction = Transaction(animation: .none) + transaction.disablesAnimations = true + + withTransaction(transaction) { + ota.retry() } - .buttonStyle(.borderedProminent) - .tint(.red) - .controlSize(.large) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + .foregroundStyle(.white) } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) } @ViewBuilder func beginWifiProcessButton() -> some View { Button { - let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) - if let connectedNode, let user = connectedNode.user { - Task { - do { - if let host { - let device = accessoryManager.activeConnection?.device - if !alreadyRebooted { - let data = try Data(contentsOf: binFileURL) - let digest = SHA256.hash(data: data) - let sha256Digest = Data(digest) - Logger.services.debug("Requesting reboot for OTA with hash: \(digest)") - - try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaWifi, otaHash: sha256Digest) - try await Task.sleep(for: .seconds(0.5)) - try await accessoryManager.disconnect() - alreadyRebooted = true - } - await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) - if let device { - try await Task.sleep(for: .seconds(3)) - try await accessoryManager.connect(to: device, retries: 5) - } - } - } catch { - Logger.mesh.error("Reboot Failed") + startWifiProcess() + } label: { + if self.inRetryWorkflow { + Label("Retry Update", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } else { + Label("Reboot & Start Update", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(accessoryManager.activeDeviceNum == nil) + } + + // MARK: - Logic + + private func startWifiProcess() { + guard let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context), + let user = connectedNode.user else { + return + } + + Task { + do { + if let host { + let device = accessoryManager.activeConnection?.device + + if !alreadyRebooted { + // Move heavy file reading/hashing off the Main Actor + let (data, sha256Digest) = try await Task.detached(priority: .userInitiated) { + let data = try Data(contentsOf: binFileURL) + let digest = SHA256.hash(data: data) + return (data, Data(digest)) + }.value + + Logger.services.debug("Requesting reboot for OTA with hash: \(sha256Digest as NSData)") + + try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaWifi, otaHash: sha256Digest) + + // Give the packet a moment to send before disconnecting + try await Task.sleep(for: .seconds(0.5)) + try await accessoryManager.disconnect() + + await MainActor.run { alreadyRebooted = true } + } + + // Begin the HTTP update + await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) + + // Attempt to reconnect after update + if let device { + try await Task.sleep(for: .seconds(3)) + try await accessoryManager.connect(to: device, retries: 5) } } + } catch { + Logger.mesh.error("ESP32 OTA Failed: \(error.localizedDescription)") } - } label: { - Label("Reboot into Wifi OTA Update Mode", systemImage: "square.and.arrow.down") - .frame(maxWidth: .infinity) - - }.buttonStyle(.bordered) - .controlSize(.large) - .disabled(accessoryManager.activeDeviceNum == nil) + } } } diff --git a/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift b/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift index 8b81a1d6..af3cc786 100644 --- a/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift +++ b/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift @@ -10,19 +10,23 @@ import SwiftUI struct CircularProgressView: View { let progress: Double var isIndeterminate: Bool = false + var isError: Bool = false var lineWidth: CGFloat = 20 var size: CGFloat = 150 var strokeColor: Color = .blue var backgroundColor: Color = .gray.opacity(0.2) + var errorColor: Color = .red var percentageFontSize: CGFloat = 48.0 - var subtitleText: String = "Loading..." - var showSubtitle: Bool = true + + // Changed to Optional, removed showSubtitle + var subtitleText: String? @State private var rotation: Double = 0 private var isComplete: Bool { - progress >= 1.0 && !isIndeterminate + // Complete only if 100%, not indeterminate, and NOT an error + progress >= 1.0 && !isIndeterminate && !isError } var body: some View { @@ -33,28 +37,28 @@ struct CircularProgressView: View { // 2. Progress circle Circle() - .trim(from: 0, to: isIndeterminate ? 0.25 : progress) + // If Error or Complete, show full circle. Else show progress/spin segment. + .trim(from: 0, to: (isIndeterminate && !isError) ? 0.25 : ((isError || isComplete) ? 1.0 : progress)) .stroke( - isComplete ? .green : strokeColor, + // Color Logic: Error > Complete > Standard + isError ? errorColor : (isComplete ? .green : strokeColor), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) ) - // Logic: If indeterminate, spin. If not, fixed at -90 (12 o'clock) - .rotationEffect(.degrees(isIndeterminate ? rotation : -90)) + // Logic: If indeterminate and NOT error, spin. Else fixed at -90 + .rotationEffect(.degrees((isIndeterminate && !isError) ? rotation : -90)) // MARK: - Animation Fix - // If indeterminate OR if progress is 0 (reset), we disable the animation (nil). - // Otherwise, we use the spring animation. + // Disable animation for Error, Indeterminate, or Reset .animation( - (isIndeterminate || progress == 0) ? nil : .spring(response: 0.6), + (isIndeterminate || isError || progress == 0) ? nil : .spring(response: 0.6), value: progress ) - - // This tells SwiftUI: "If isIndeterminate changes, this is a NEW view." - // This forces the old spinning view to be destroyed and a new static view to be created. .id(isIndeterminate) - // 3. Content - if isComplete { + // 3. Content Logic + if isError { + errorView + } else if isComplete { completedView } else { inProgressView @@ -64,23 +68,19 @@ struct CircularProgressView: View { .onAppear { updateAnimationStatus() } - .onChange(of: isIndeterminate) { _, _ in - updateAnimationStatus() - } + // Monitor both Indeterminate and Error to stop/start animations + .onChange(of: isIndeterminate) { _, _ in updateAnimationStatus() } + .onChange(of: isError) { _, _ in updateAnimationStatus() } } private func updateAnimationStatus() { - if isIndeterminate { - // Reset rotation to 0 without animation to start clean + // Only spin if Indeterminate AND we are not in an Error state + if isIndeterminate && !isError { rotation = 0 - // Start the infinite spin withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) { rotation = 360 } } else { - // Determine mode: The .id() modifier handles the visual stop, - // but we reset the state here for cleanliness. - // We use a transaction to disable animations for this state reset. var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { @@ -89,7 +89,8 @@ struct CircularProgressView: View { } } - // Extracted views... + // MARK: - Subviews + private var completedView: some View { ZStack { Circle() @@ -103,6 +104,33 @@ struct CircularProgressView: View { .transition(.scale.combined(with: .opacity)) } + private var errorView: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(errorColor.opacity(0.15)) + .frame(width: size * 0.5, height: size * 0.5) + + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: percentageFontSize, weight: .bold)) + .foregroundColor(errorColor) + } + + // Unwrapped optional check + if let subtitleText { + Text(subtitleText) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + .padding(.horizontal, 10) + } + } + .transition(.scale.combined(with: .opacity)) + } + private var inProgressView: some View { VStack(spacing: 8) { if !isIndeterminate { @@ -110,8 +138,6 @@ struct CircularProgressView: View { .font(.system(size: percentageFontSize, weight: .bold)) .foregroundColor(.primary) .contentTransition(.numericText()) - // MARK: - Text Animation Fix - // Prevent the numbers from "rolling down" when resetting to 0 .animation(progress == 0 ? nil : .default, value: progress) } else { Image(systemName: "clock") @@ -119,8 +145,9 @@ struct CircularProgressView: View { .foregroundColor(strokeColor) } - if showSubtitle { - Text(isIndeterminate && subtitleText == "Loading..." ? "Please wait" : subtitleText) + // Unwrapped optional check + if let text = subtitleText { + Text(isIndeterminate && text == "Loading..." ? "Please wait" : text) .font(.callout) .foregroundColor(.secondary) } @@ -128,3 +155,24 @@ struct CircularProgressView: View { .transition(.opacity) } } + +// MARK: - Preview +#Preview { + VStack(spacing: 40) { + // Standard Progress with subtitle + CircularProgressView(progress: 0.45, subtitleText: "Syncing...") + .frame(height: 150) + + // Error State with subtitle + CircularProgressView( + progress: 0.45, + isError: true, + subtitleText: "Connection Failed" + ) + .frame(height: 150) + + // No Subtitle + CircularProgressView(progress: 0.75, subtitleText: nil) + .frame(height: 150) + } +}