UI improvements

This commit is contained in:
Jake-B 2026-01-06 16:40:00 -05:00
parent c42df05ea4
commit 97d2530f99
5 changed files with 372 additions and 246 deletions

View file

@ -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 %@)" : {

View file

@ -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
}
}
}
}

View file

@ -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)
// }
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}