mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Fix for UIObservationTrackingFeedbackLoopDetected
This commit is contained in:
parent
209ce72f92
commit
6dd250232c
5 changed files with 337 additions and 257 deletions
|
|
@ -16744,6 +16744,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Hardware not found for %@" : {
|
||||
"comment" : "A label indicating that the view is displaying a message because it couldn't find any hardware information for the current node. The argument is the name of the node.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hazardous" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
|
|||
|
|
@ -106,13 +106,15 @@ class FirmwareViewModel: ObservableObject {
|
|||
Logger.services.error("Error loading firmware files: \(error)")
|
||||
}
|
||||
|
||||
// Keep the list sorted by version, with deterministic ordering of the firmware type
|
||||
self.firmwareFiles = newFirmwareList.values.sorted {
|
||||
if ($0.versionMajor, $0.versionMinor, $0.versionPatch) == ($1.versionMajor, $1.versionMinor, $1.versionPatch) {
|
||||
// If versions are equal, sort by firmwareType (assuming it's String or Comparable)
|
||||
return String(describing: $0.firmwareType) < String(describing: $1.firmwareType)
|
||||
Task { @MainActor in
|
||||
// Keep the list sorted by version, with deterministic ordering of the firmware type
|
||||
self.firmwareFiles = newFirmwareList.values.sorted {
|
||||
if ($0.versionMajor, $0.versionMinor, $0.versionPatch) == ($1.versionMajor, $1.versionMinor, $1.versionPatch) {
|
||||
// If versions are equal, sort by firmwareType (assuming it's String or Comparable)
|
||||
return String(describing: $0.firmwareType) < String(describing: $1.firmwareType)
|
||||
}
|
||||
return ($0.versionMajor, $0.versionMinor, $0.versionPatch) > ($1.versionMajor, $1.versionMinor, $1.versionPatch)
|
||||
}
|
||||
return ($0.versionMajor, $0.versionMinor, $0.versionPatch) > ($1.versionMajor, $1.versionMinor, $1.versionPatch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ import SwiftUI
|
|||
import CoreData
|
||||
import SwiftDraw
|
||||
|
||||
struct DeviceHardwareImage<T>: View where T: BinaryInteger, T: CVarArg {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@FetchRequest var hardware: FetchedResults<DeviceHardwareEntity>
|
||||
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
|
||||
// 1. THE LOADER (Public API)
|
||||
// Responsibilities: Construct the FetchRequest only.
|
||||
// It creates no heavy objects and runs no logic in init.
|
||||
struct DeviceHardwareImage: View {
|
||||
|
||||
// This closure lets the caller define modifiers on the Image
|
||||
@State private var gridSize: CGSize = .zero
|
||||
// We hold the fetch request here
|
||||
@FetchRequest var hardwareResults: FetchedResults<DeviceHardwareEntity>
|
||||
|
||||
init(hwId: T) {
|
||||
|
||||
// Init for Integer ID
|
||||
init<T>(hwId: T) where T: BinaryInteger, T: CVarArg {
|
||||
let predicate = NSPredicate(format: "hwModel == %d", hwId)
|
||||
_hardware = FetchRequest(
|
||||
_hardwareResults = FetchRequest(
|
||||
entity: DeviceHardwareEntity.entity(),
|
||||
sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)],
|
||||
predicate: predicate,
|
||||
|
|
@ -28,148 +28,161 @@ struct DeviceHardwareImage<T>: View where T: BinaryInteger, T: CVarArg {
|
|||
)
|
||||
}
|
||||
|
||||
var potentialImages: [DeviceHardwareImageEntity] {
|
||||
var returnImages = [DeviceHardwareImageEntity]()
|
||||
var seenFileNames = Set<String>()
|
||||
for item in hardware {
|
||||
if let imageList = item.images as? Set<DeviceHardwareImageEntity> {
|
||||
for image in imageList {
|
||||
if image.svgData != nil {
|
||||
let name = image.fileName ?? ""
|
||||
if !seenFileNames.contains(name) {
|
||||
seenFileNames.insert(name)
|
||||
returnImages.append(image)
|
||||
}
|
||||
}
|
||||
if returnImages.count >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort to keep the order somewhat deterministic
|
||||
return returnImages.sorted(by: {$0.fileName ?? "" < $1.fileName ?? ""})
|
||||
// Init for String Target
|
||||
init(platformioTarget: String) {
|
||||
let predicate = NSPredicate(format: "platformioTarget == %@", platformioTarget)
|
||||
_hardwareResults = FetchRequest(
|
||||
entity: DeviceHardwareEntity.entity(),
|
||||
sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)],
|
||||
predicate: predicate,
|
||||
animation: .default
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// 1. Define the footprint.
|
||||
// We use Color.clear so it takes up space but is invisible.
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit) // Enforce square aspect ratio (or change as needed)
|
||||
// 2. Measure the size of this footprint using the new modifier
|
||||
.onGeometryChange(for: CGSize.self) { proxy in
|
||||
proxy.size
|
||||
} action: { newValue in
|
||||
gridSize = newValue
|
||||
// Pass the raw fetched results to the logic layer
|
||||
DeviceHardwareImageProcessor(hardware: hardwareResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. THE PROCESSOR (Internal)
|
||||
// Responsibilities: Convert Core Data Entities into a flat array of images.
|
||||
// This uses .task to step out of the Layout Loop.
|
||||
private struct DeviceHardwareImageProcessor: View {
|
||||
let hardware: FetchedResults<DeviceHardwareEntity>
|
||||
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
|
||||
|
||||
// We buffer the processed images in State.
|
||||
// This prevents the Layout pass from triggering Core Data faults.
|
||||
@State private var sortedImages: [DeviceHardwareImageEntity] = []
|
||||
|
||||
var body: some View {
|
||||
DeviceHardwareImageLayout(
|
||||
images: sortedImages,
|
||||
isLoading: meshtasticAPI.isLoadingDeviceList
|
||||
)
|
||||
.task(id: hardware.count) {
|
||||
// Re-calculate only when the hardware list actually changes,
|
||||
// NOT when the scrollview bounces or layout shifts.
|
||||
self.sortedImages = processImages()
|
||||
}
|
||||
}
|
||||
|
||||
// The heavy logic moved out of the computed property
|
||||
private func processImages() -> [DeviceHardwareImageEntity] {
|
||||
var returnImages = [DeviceHardwareImageEntity]()
|
||||
var seenFileNames = Set<String>()
|
||||
|
||||
// This traversal happens in the background task now
|
||||
for item in hardware {
|
||||
guard let imageList = item.images as? Set<DeviceHardwareImageEntity> else { continue }
|
||||
|
||||
for image in imageList {
|
||||
if image.svgData != nil {
|
||||
let name = image.fileName ?? ""
|
||||
if !seenFileNames.contains(name) {
|
||||
seenFileNames.insert(name)
|
||||
returnImages.append(image)
|
||||
}
|
||||
}
|
||||
if returnImages.count >= 4 { break }
|
||||
}
|
||||
// 3. Draw the actual content on top using the measured size
|
||||
if returnImages.count >= 4 { break }
|
||||
}
|
||||
|
||||
return returnImages.sorted(by: { $0.fileName ?? "" < $1.fileName ?? "" })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. THE LAYOUT (Pure UI)
|
||||
// Responsibilities: Draw boxes. No Core Data knowledge.
|
||||
private struct DeviceHardwareImageLayout: View {
|
||||
let images: [DeviceHardwareImageEntity]
|
||||
let isLoading: Bool
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay {
|
||||
let images = self.potentialImages
|
||||
if images.count > 0, gridSize != .zero {
|
||||
content(size: gridSize, images: self.potentialImages)
|
||||
} else if meshtasticAPI.isLoadingDeviceList {
|
||||
ProgressView()
|
||||
if images.isEmpty {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image("UNSET")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
grid(images: images)
|
||||
}
|
||||
}
|
||||
.clipped() // Essential for ScrollView stability
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(size: CGSize, images: [DeviceHardwareImageEntity]) -> some View {
|
||||
private func grid(images: [DeviceHardwareImageEntity]) -> some View {
|
||||
let spacing: CGFloat = 10.0
|
||||
|
||||
switch images.count {
|
||||
case 0:
|
||||
Image("UNSET")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size.width, height: size.height)
|
||||
|
||||
case 1:
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size.width, height: size.height)
|
||||
}
|
||||
SingleImageView(entity: images[0])
|
||||
|
||||
case 2:
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(0..<2, id: \.self) { i in
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: (size.width - 2) / 2,
|
||||
height: size.height)
|
||||
}
|
||||
}
|
||||
SingleImageView(entity: images[0])
|
||||
SingleImageView(entity: images[1])
|
||||
}
|
||||
|
||||
case 3:
|
||||
HStack(spacing: spacing) {
|
||||
// Big image on the Left
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: (size.width * 0.6) - 1,
|
||||
height: size.height)
|
||||
}
|
||||
|
||||
// Two stacked on the Right
|
||||
VStack(spacing: spacing) {
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Flex fill
|
||||
}
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Flex fill
|
||||
GeometryReader { proxy in
|
||||
HStack(spacing: spacing) {
|
||||
SingleImageView(entity: images[0])
|
||||
.frame(width: floor(proxy.size.width * 0.6))
|
||||
|
||||
VStack(spacing: spacing) {
|
||||
SingleImageView(entity: images[1])
|
||||
SingleImageView(entity: images[2])
|
||||
}
|
||||
}
|
||||
.frame(width: (size.width * 0.4) - 1,
|
||||
height: size.height)
|
||||
}
|
||||
|
||||
default: // 4 items
|
||||
let halfWidth = (size.width - 2) / 2
|
||||
let halfHeight = (size.height - 2) / 2
|
||||
|
||||
default: // 4 or more
|
||||
VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: halfWidth, height: halfHeight)
|
||||
}
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: halfWidth, height: halfHeight)
|
||||
}
|
||||
SingleImageView(entity: images[0])
|
||||
SingleImageView(entity: images[1])
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: halfWidth, height: halfHeight)
|
||||
}
|
||||
if let svgData = images[0].svgData, let svg = SVG(data: svgData) {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: halfWidth, height: halfHeight)
|
||||
}
|
||||
SingleImageView(entity: images[2])
|
||||
SingleImageView(entity: images[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. THE LEAF VIEW
|
||||
// Responsibilities: safely load SVG data
|
||||
private struct SingleImageView: View {
|
||||
let entity: DeviceHardwareImageEntity
|
||||
@State private var svg: SVG?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let svg = svg {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Parse SVG once, prevents lag during scroll/layout
|
||||
if self.svg == nil, let data = entity.svgData {
|
||||
self.svg = SVG(data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,13 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct SupportedHardwareBadge<T>: View where T: BinaryInteger, T: CVarArg {
|
||||
let hwModelId: T
|
||||
struct SupportedHardwareBadge: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@FetchRequest var hardware: FetchedResults<DeviceHardwareEntity>
|
||||
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
|
||||
|
||||
init(hwModelId: T) {
|
||||
self.hwModelId = hwModelId
|
||||
init<T>(hwModelId: T) where T: BinaryInteger, T: CVarArg {
|
||||
let predicate = NSPredicate(format: "hwModel == %d", hwModelId)
|
||||
_hardware = FetchRequest(
|
||||
entity: DeviceHardwareEntity.entity(),
|
||||
|
|
@ -25,6 +23,16 @@ struct SupportedHardwareBadge<T>: View where T: BinaryInteger, T: CVarArg {
|
|||
)
|
||||
}
|
||||
|
||||
init(platformioTarget: String) {
|
||||
let predicate = NSPredicate(format: "platformioTarget == %@", platformioTarget)
|
||||
_hardware = FetchRequest(
|
||||
entity: DeviceHardwareEntity.entity(),
|
||||
sortDescriptors: [NSSortDescriptor(key: "hwModelSlug", ascending: true)],
|
||||
predicate: predicate,
|
||||
animation: .default
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch hardware.count {
|
||||
case 1:
|
||||
|
|
@ -50,7 +58,7 @@ struct SupportedHardwareBadge<T>: View where T: BinaryInteger, T: CVarArg {
|
|||
} else {
|
||||
// Can't find this hardware in the database
|
||||
VStack {
|
||||
Image(systemName:"questionmark.circle.fill")
|
||||
Image(systemName: "questionmark.circle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.gray)
|
||||
Text("Unknown")
|
||||
|
|
|
|||
|
|
@ -8,55 +8,159 @@
|
|||
import SwiftUI
|
||||
import StoreKit
|
||||
import OSLog
|
||||
import SwiftDraw
|
||||
|
||||
// 1. THE WRAPPER
|
||||
// This handles the fetching safely. It does not run logic in init.
|
||||
struct Firmware: View {
|
||||
|
||||
private enum FirmwareTab {
|
||||
case stable
|
||||
case alpha
|
||||
case downloaded
|
||||
}
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
let node: NodeInfoEntity
|
||||
let hardware: DeviceHardwareEntity
|
||||
@State var minimumVersion = "2.6.11"
|
||||
@State var version = ""
|
||||
@State private var currentDevice: DeviceHardware?
|
||||
|
||||
@State private var firmwareSelection = FirmwareTab.stable
|
||||
|
||||
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
|
||||
|
||||
@StateObject var firmwareList: FirmwareViewModel
|
||||
// Use SwiftUI's native FetchRequest mechanism
|
||||
@FetchRequest var hardwareResults: FetchedResults<DeviceHardwareEntity>
|
||||
|
||||
init?(node: NodeInfoEntity?) {
|
||||
guard let node else { return nil }
|
||||
guard let node = node, let pioEnv = node.myInfo?.pioEnv else { return nil }
|
||||
self.node = node
|
||||
|
||||
let fetchRequest = DeviceHardwareEntity.fetchRequest()
|
||||
guard let pioEnv = node.myInfo?.pioEnv else { return nil }
|
||||
fetchRequest.predicate = NSPredicate(format: "platformioTarget == %@", pioEnv)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
// Can't use the @Environment because we don't have self yet.
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
guard let result = try? context.fetch(fetchRequest).first else {
|
||||
return nil
|
||||
}
|
||||
hardware = result
|
||||
_firmwareList = StateObject(wrappedValue: FirmwareViewModel(forHardware: result))
|
||||
let predicate = NSPredicate(format: "platformioTarget == %@", pioEnv)
|
||||
_hardwareResults = FetchRequest(
|
||||
entity: DeviceHardwareEntity.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: predicate,
|
||||
animation: .default
|
||||
)
|
||||
}
|
||||
|
||||
var myVersion: String? {
|
||||
return node.metadata?.firmwareVersion
|
||||
var body: some View {
|
||||
if let hardware = hardwareResults.first {
|
||||
FirmwareContentView(node: node, hardware: hardware)
|
||||
} else {
|
||||
// Fallback content
|
||||
List {
|
||||
Text("Hardware not found for \(node.myInfo?.pioEnv ?? "unknown")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. THE CONTENT
|
||||
// Decoupled from fetching logic.
|
||||
private struct FirmwareContentView: View {
|
||||
|
||||
private enum FirmwareTab {
|
||||
case stable, alpha, downloaded
|
||||
}
|
||||
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@EnvironmentObject var meshtasticAPI: MeshtasticAPI
|
||||
|
||||
let node: NodeInfoEntity
|
||||
let hardware: DeviceHardwareEntity
|
||||
|
||||
// We can safely init the StateObject here because 'hardware' is passed in
|
||||
@StateObject var firmwareList: FirmwareViewModel
|
||||
@State private var firmwareSelection = FirmwareTab.stable
|
||||
|
||||
init(node: NodeInfoEntity, hardware: DeviceHardwareEntity) {
|
||||
self.node = node
|
||||
self.hardware = hardware
|
||||
_firmwareList = StateObject(wrappedValue: FirmwareViewModel(forHardware: hardware))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// SECTION 1: HERO
|
||||
Section {
|
||||
HStack {
|
||||
SupportedHardwareBadge(hwModelId: hardware.hwModel)
|
||||
Text("Device Model: \(hardware.displayName ?? "Unknown")")
|
||||
.font(.largeTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack {
|
||||
FirmwareHeroImage(hardware: hardware)
|
||||
.frame(height: 300) // Give List a hint of the height
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Platform IO").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(node.myInfo?.pioEnv ?? "Unknown")")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Architecture").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(hardware.architecture ?? "Unknown")")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Current Firmware Version").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(node.metadata?.firmwareVersion ?? "Unknown")")
|
||||
}
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
// SECTION 2: RELEASES
|
||||
Section(header: releasesHeader, footer: lastUpdatedFooter) {
|
||||
Picker("Firmware Version", selection: $firmwareSelection) {
|
||||
Text("Stable").tag(FirmwareTab.stable)
|
||||
Text("Alpha").tag(FirmwareTab.alpha)
|
||||
Text("Downloaded").tag(FirmwareTab.downloaded)
|
||||
}.pickerStyle(.segmented)
|
||||
|
||||
// Extracted switch logic to keep body clean
|
||||
firmwareRows
|
||||
}
|
||||
}
|
||||
.navigationTitle("Firmware Updates")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
@ViewBuilder
|
||||
var firmwareLastUpdatedFooter: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0.0) {
|
||||
if self.meshtasticAPI.isLoadingFirmwareList {
|
||||
var firmwareRows: some View {
|
||||
switch firmwareSelection {
|
||||
case .stable:
|
||||
let stables = firmwareList.mostRecentFirmware(forReleaseType: .stable)
|
||||
ForEach(stables, id: \.localUrl) { release in
|
||||
FirmwareRow(firmwareFile: release)
|
||||
}
|
||||
if let last = stables.last, let notes = last.releaseNotes {
|
||||
NavigationLink("Release Notes") {
|
||||
ScrollView { Text(notes).padding() }
|
||||
.navigationTitle("\(last.versionId)")
|
||||
}
|
||||
}
|
||||
case .alpha:
|
||||
let alphas = firmwareList.mostRecentFirmware(forReleaseType: .alpha)
|
||||
ForEach(alphas, id: \.localUrl) { release in
|
||||
FirmwareRow(firmwareFile: release)
|
||||
}
|
||||
if let last = alphas.last, let notes = last.releaseNotes {
|
||||
NavigationLink("Release Notes") {
|
||||
ScrollView { Text(notes).padding() }
|
||||
.navigationTitle("\(last.versionId)")
|
||||
}
|
||||
}
|
||||
case .downloaded:
|
||||
let downloads = firmwareList.downloadedFirmware(includeInProgressDownloads: true)
|
||||
if downloads.isEmpty {
|
||||
Text("No firmware has been downloaded for this device.")
|
||||
} else {
|
||||
ForEach(downloads, id: \.localUrl) { file in
|
||||
FirmwareRow(firmwareFile: file)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let files = offsets.map { downloads[$0] }
|
||||
firmwareList.delete(files)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastUpdatedFooter: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
if meshtasticAPI.isLoadingFirmwareList {
|
||||
Text("Updating now...")
|
||||
} else {
|
||||
if UserDefaults.lastFirmwareAPIUpdate == .distantPast {
|
||||
|
|
@ -68,111 +172,60 @@ struct Firmware: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var fimwareReleasesHeader: some View {
|
||||
var releasesHeader: some View {
|
||||
HStack {
|
||||
Text("Firmware Releases")
|
||||
Spacer()
|
||||
if meshtasticAPI.isLoadingFirmwareList {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
Button("Check For Updates") {
|
||||
Task.detached {
|
||||
try? await meshtasticAPI.refreshFirmwareAPIData()
|
||||
}
|
||||
} label: {
|
||||
Text("Check For Updates")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var dfuViewModel = DFUViewModel()
|
||||
}
|
||||
|
||||
// 3. THE ISOLATED HERO IMAGE
|
||||
// This stops an infinite rendering loop. It loads the SVG data once into State,
|
||||
// preventing the List layout pass from triggering Core Data faults repeatedly.
|
||||
struct FirmwareHeroImage: View {
|
||||
let hardware: DeviceHardwareEntity
|
||||
@State private var svg: SVG?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// Hero image of the node
|
||||
Section {
|
||||
HStack {
|
||||
SupportedHardwareBadge(hwModelId: hardware.hwModel)
|
||||
Text("Device Model: \(hardware.displayName ?? "Unknown")")
|
||||
.font(.largeTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
VStack(alignment: .center) {
|
||||
DeviceHardwareImage(hwId: node.user?.hwModelId ?? 0)
|
||||
.frame(width: 300, height: 300)
|
||||
.cornerRadius(5)
|
||||
}.frame(maxWidth: .infinity) // Make sure the center is honored by filling the width
|
||||
VStack(alignment: .leading) {
|
||||
Text("Platform IO").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(node.myInfo?.pioEnv, default: "Unknown")")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Architecture").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(self.hardware.architecture, default: "Unknown")")
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Current Firmware Version").font(.caption).foregroundColor(.secondary)
|
||||
Text("\(self.myVersion, default: "Unknown")")
|
||||
}
|
||||
}.listRowSeparator(.hidden) // Hides lines between rows
|
||||
|
||||
Section(header: self.fimwareReleasesHeader, footer: self.firmwareLastUpdatedFooter) {
|
||||
Picker("Firmware Version", selection: $firmwareSelection) {
|
||||
Text("Stable").tag(FirmwareTab.stable)
|
||||
Text("Alpha").tag(FirmwareTab.alpha)
|
||||
Text("Downloaded").tag(FirmwareTab.downloaded)
|
||||
}.pickerStyle(.segmented)
|
||||
|
||||
switch firmwareSelection {
|
||||
case .stable:
|
||||
let stables = firmwareList.mostRecentFirmware(forReleaseType: .stable)
|
||||
ForEach(stables, id: \.localUrl) { release in
|
||||
FirmwareRow(firmwareFile: release)
|
||||
}
|
||||
if let lastStable = stables.last, let notes = lastStable.releaseNotes {
|
||||
NavigationLink {
|
||||
ScrollView {
|
||||
Text(notes)
|
||||
.padding()
|
||||
}.navigationTitle("\(lastStable.versionId, default: "ReleaseNotes")")
|
||||
} label: {
|
||||
Text("Release Notes")
|
||||
}
|
||||
}
|
||||
case .alpha:
|
||||
let alphas = firmwareList.mostRecentFirmware(forReleaseType: .alpha)
|
||||
ForEach(alphas, id: \.localUrl) { release in
|
||||
FirmwareRow(firmwareFile: release)
|
||||
}
|
||||
if let lastAlpha = alphas.last, let notes = lastAlpha.releaseNotes {
|
||||
NavigationLink {
|
||||
ScrollView {
|
||||
Text(notes)
|
||||
.padding()
|
||||
}.navigationTitle("\(lastAlpha.versionId, default: "ReleaseNotes")")
|
||||
} label: {
|
||||
Text("Release Notes")
|
||||
}
|
||||
}
|
||||
case .downloaded:
|
||||
let downloadedFirmware = firmwareList.downloadedFirmware(includeInProgressDownloads: true)
|
||||
if downloadedFirmware.count > 0 {
|
||||
ForEach(downloadedFirmware, id: \.localUrl) { firmwareFile in
|
||||
FirmwareRow(firmwareFile: firmwareFile)
|
||||
}.onDelete { offsets in
|
||||
let filesToDelete = offsets.map { downloadedFirmware[$0] }
|
||||
firmwareList.delete(filesToDelete)
|
||||
}
|
||||
} else {
|
||||
Text("No firmware has been downloaded for this device.")
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if let svg = svg {
|
||||
SVGView(svg: svg)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 300, height: 300)
|
||||
.cornerRadius(5)
|
||||
} else {
|
||||
// Placeholder prevents List jumpiness while loading
|
||||
Color.clear
|
||||
.frame(width: 300, height: 300)
|
||||
}
|
||||
.navigationTitle("Firmware Updates")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
// Perform the Core Data relationship traversal off the main layout pass
|
||||
if svg == nil {
|
||||
self.svg = getSVG()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getSVG() -> SVG? {
|
||||
let images = hardware.images as? Set<DeviceHardwareImageEntity> ?? []
|
||||
if let image = images.first,
|
||||
let data = image.svgData,
|
||||
let svg = SVG(data: data) {
|
||||
return svg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue