mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
UI improvements
This commit is contained in:
parent
c42df05ea4
commit
97d2530f99
5 changed files with 372 additions and 246 deletions
|
|
@ -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 %@)" : {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue