@@ -62,7 +62,7 @@ class BluetoothManager : NSObject, ObservableObject {
|
||
| 62 | 62 |
|
| 63 | 63 |
let macAddress = MACAddress(from: manufacturerData.suffix(from: 2)) |
| 64 | 64 |
let macAddressString = macAddress.description |
| 65 |
- appData.registerKnownMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName) |
|
| 65 |
+ appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName) |
|
| 66 | 66 |
appData.noteMeterSeen(at: Date(), macAddress: macAddressString) |
| 67 | 67 |
|
| 68 | 68 |
if appData.meters[peripheral.identifier] == nil {
|
@@ -159,7 +159,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 159 | 159 |
usbMeter.btSerial.connectionEstablished() |
| 160 | 160 |
} |
| 161 | 161 |
else {
|
| 162 |
- track("Connected to unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 162 |
+ track("Connected to meter with UUID: '\(peripheral.identifier)'")
|
|
| 163 | 163 |
} |
| 164 | 164 |
} |
| 165 | 165 |
|
@@ -170,7 +170,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 170 | 170 |
usbMeter.btSerial.connectionClosed() |
| 171 | 171 |
} |
| 172 | 172 |
else {
|
| 173 |
- track("Disconnected from unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 173 |
+ track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
|
|
| 174 | 174 |
} |
| 175 | 175 |
} |
| 176 | 176 |
|
@@ -179,7 +179,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 179 | 179 |
if let usbMeter = appData.meters[peripheral.identifier] {
|
| 180 | 180 |
usbMeter.btSerial.connectionClosed() |
| 181 | 181 |
} else {
|
| 182 |
- track("Failed to connect to unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 182 |
+ track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
|
|
| 183 | 183 |
} |
| 184 | 184 |
} |
| 185 | 185 |
} |
@@ -57,7 +57,7 @@ final class MeterNameStore {
|
||
| 57 | 57 |
static let shared = MeterNameStore() |
| 58 | 58 |
|
| 59 | 59 |
private enum Keys {
|
| 60 |
- static let knownMeters = "MeterNameStore.knownMeters" |
|
| 60 |
+ static let meters = "MeterNameStore.meters" |
|
| 61 | 61 |
static let localMeterNames = "MeterNameStore.localMeterNames" |
| 62 | 62 |
static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits" |
| 63 | 63 |
static let localModelNames = "MeterNameStore.localModelNames" |
@@ -128,15 +128,15 @@ final class MeterNameStore {
|
||
| 128 | 128 |
return dateDictionary(for: Keys.localLastConnected)[normalizedMAC] |
| 129 | 129 |
} |
| 130 | 130 |
|
| 131 |
- func registerKnownMeter(macAddress: String, modelName: String?, advertisedName: String?) {
|
|
| 131 |
+ func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
|
|
| 132 | 132 |
let normalizedMAC = normalizedMACAddress(macAddress) |
| 133 | 133 |
guard !normalizedMAC.isEmpty else {
|
| 134 |
- track("MeterNameStore ignored known meter registration with invalid MAC '\(macAddress)'")
|
|
| 134 |
+ track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
|
|
| 135 | 135 |
return |
| 136 | 136 |
} |
| 137 | 137 |
|
| 138 | 138 |
var didChange = false |
| 139 |
- didChange = updateKnownMeters(normalizedMAC) || didChange |
|
| 139 |
+ didChange = updateMetersSet(normalizedMAC) || didChange |
|
| 140 | 140 |
didChange = updateDictionaryValue( |
| 141 | 141 |
for: normalizedMAC, |
| 142 | 142 |
value: normalizedName(modelName), |
@@ -171,7 +171,7 @@ final class MeterNameStore {
|
||
| 171 | 171 |
} |
| 172 | 172 |
|
| 173 | 173 |
var didChange = false |
| 174 |
- didChange = updateKnownMeters(normalizedMAC) || didChange |
|
| 174 |
+ didChange = updateMetersSet(normalizedMAC) || didChange |
|
| 175 | 175 |
|
| 176 | 176 |
if let name {
|
| 177 | 177 |
didChange = updateDictionaryValue( |
@@ -203,7 +203,7 @@ final class MeterNameStore {
|
||
| 203 | 203 |
let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults) |
| 204 | 204 |
let lastSeenValues = dateDictionary(for: Keys.localLastSeen) |
| 205 | 205 |
let lastConnectedValues = dateDictionary(for: Keys.localLastConnected) |
| 206 |
- let macAddresses = knownMeters() |
|
| 206 |
+ let macAddresses = meters() |
|
| 207 | 207 |
.union(names.keys) |
| 208 | 208 |
.union(temperatureUnits.keys) |
| 209 | 209 |
.union(modelNames.keys) |
@@ -253,8 +253,8 @@ final class MeterNameStore {
|
||
| 253 | 253 |
return rawValues.mapValues(Date.init(timeIntervalSince1970:)) |
| 254 | 254 |
} |
| 255 | 255 |
|
| 256 |
- private func knownMeters() -> Set<String> {
|
|
| 257 |
- Set((defaults.array(forKey: Keys.knownMeters) as? [String]) ?? []) |
|
| 256 |
+ private func meters() -> Set<String> {
|
|
| 257 |
+ Set((defaults.array(forKey: Keys.meters) as? [String]) ?? []) |
|
| 258 | 258 |
} |
| 259 | 259 |
|
| 260 | 260 |
private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
|
@@ -266,12 +266,12 @@ final class MeterNameStore {
|
||
| 266 | 266 |
} |
| 267 | 267 |
|
| 268 | 268 |
@discardableResult |
| 269 |
- private func updateKnownMeters(_ macAddress: String) -> Bool {
|
|
| 270 |
- var known = knownMeters() |
|
| 269 |
+ private func updateMetersSet(_ macAddress: String) -> Bool {
|
|
| 270 |
+ var known = meters() |
|
| 271 | 271 |
let initialCount = known.count |
| 272 | 272 |
known.insert(macAddress) |
| 273 | 273 |
guard known.count != initialCount else { return false }
|
| 274 |
- defaults.set(Array(known).sorted(), forKey: Keys.knownMeters) |
|
| 274 |
+ defaults.set(Array(known).sorted(), forKey: Keys.meters) |
|
| 275 | 275 |
return true |
| 276 | 276 |
} |
| 277 | 277 |
|
@@ -329,7 +329,7 @@ final class MeterNameStore {
|
||
| 329 | 329 |
guard values[normalizedMAC] != timeInterval else { return }
|
| 330 | 330 |
values[normalizedMAC] = timeInterval |
| 331 | 331 |
defaults.set(values, forKey: key) |
| 332 |
- _ = updateKnownMeters(normalizedMAC) |
|
| 332 |
+ _ = updateMetersSet(normalizedMAC) |
|
| 333 | 333 |
notifyChange() |
| 334 | 334 |
} |
| 335 | 335 |
|
@@ -1,32 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// BorderView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 11/04/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct BorderView: View {
|
|
| 12 |
- let show: Bool |
|
| 13 |
- var fillColor: Color = .clear |
|
| 14 |
- var opacity = 0.5 |
|
| 15 |
- |
|
| 16 |
- var body: some View {
|
|
| 17 |
- ZStack {
|
|
| 18 |
- RoundedRectangle(cornerRadius: 10) |
|
| 19 |
- .foregroundColor(fillColor).opacity(opacity) |
|
| 20 |
- |
|
| 21 |
- RoundedRectangle(cornerRadius: 10) |
|
| 22 |
- .stroke(lineWidth: 3.0).foregroundColor(show ? fillColor : Color.clear) |
|
| 23 |
- .animation(.linear(duration: 0.1), value: show) |
|
| 24 |
- } |
|
| 25 |
- } |
|
| 26 |
-} |
|
| 27 |
- |
|
| 28 |
-struct BorderView_Previews: PreviewProvider {
|
|
| 29 |
- static var previews: some View {
|
|
| 30 |
- BorderView(show: true) |
|
| 31 |
- } |
|
| 32 |
-} |
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// SwiftUIView.swift |
|
| 2 |
+// ChevronView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 02/05/2020. |
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// SwiftUIView.swift |
|
| 2 |
+// RSSIView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 14/03/2020. |
@@ -54,7 +54,7 @@ struct RSSIView: View {
|
||
| 54 | 54 |
} |
| 55 | 55 |
|
| 56 | 56 |
|
| 57 |
-struct SwiftUIView_Previews: PreviewProvider {
|
|
| 57 |
+struct RSSIView_Previews: PreviewProvider {
|
|
| 58 | 58 |
static var previews: some View {
|
| 59 | 59 |
RSSIView(RSSI: -80).frame(width: 20, height: 20, alignment: .center) |
| 60 | 60 |
} |
@@ -41,25 +41,10 @@ struct MeterView: View {
|
||
| 41 | 41 |
|
| 42 | 42 |
private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac |
| 43 | 43 |
|
| 44 |
- @State var dataGroupsViewVisibility: Bool = false |
|
| 45 |
- @State var recordingViewVisibility: Bool = false |
|
| 46 |
- @State var measurementsViewVisibility: Bool = false |
|
| 47 | 44 |
@State private var selectedMeterTab: MeterTab = .connection |
| 48 | 45 |
@State private var navBarTitle: String = "Meter" |
| 49 | 46 |
@State private var navBarShowRSSI: Bool = false |
| 50 | 47 |
@State private var navBarRSSI: Int = 0 |
| 51 |
- @State private var editingName = false |
|
| 52 |
- @State private var editingScreenTimeout = false |
|
| 53 |
- @State private var editingScreenBrightness = false |
|
| 54 |
- private var myBounds: CGRect { UIScreen.main.bounds }
|
|
| 55 |
- private let actionStripPadding: CGFloat = 10 |
|
| 56 |
- private let actionDividerWidth: CGFloat = 1 |
|
| 57 |
- private let actionButtonMaxWidth: CGFloat = 156 |
|
| 58 |
- private let actionButtonMinWidth: CGFloat = 88 |
|
| 59 |
- private let actionButtonHeight: CGFloat = 108 |
|
| 60 |
- private let pageHorizontalPadding: CGFloat = 12 |
|
| 61 |
- private let pageVerticalPadding: CGFloat = 12 |
|
| 62 |
- private let contentCardPadding: CGFloat = 16 |
|
| 63 | 48 |
|
| 64 | 49 |
var body: some View {
|
| 65 | 50 |
GeometryReader { proxy in
|
@@ -258,21 +243,25 @@ struct MeterView: View {
|
||
| 258 | 243 |
private func landscapeSegmentedContent(size: CGSize) -> some View {
|
| 259 | 244 |
switch selectedMeterTab {
|
| 260 | 245 |
case .connection: |
| 261 |
- landscapeConnectionPage |
|
| 246 |
+ MeterConnectionTabView(size: size, isLandscape: true) |
|
| 262 | 247 |
case .live: |
| 263 | 248 |
if meter.operationalState == .dataIsAvailable {
|
| 264 |
- landscapeLivePage(size: size) |
|
| 249 |
+ MeterLiveTabView(size: size, isLandscape: true) |
|
| 265 | 250 |
} else {
|
| 266 |
- landscapeConnectionPage |
|
| 251 |
+ MeterConnectionTabView(size: size, isLandscape: true) |
|
| 267 | 252 |
} |
| 268 | 253 |
case .chart: |
| 269 | 254 |
if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
|
| 270 |
- landscapeChartPage(size: size) |
|
| 255 |
+ MeterChartTabView(size: size, isLandscape: true) |
|
| 271 | 256 |
} else {
|
| 272 |
- landscapeConnectionPage |
|
| 257 |
+ MeterConnectionTabView(size: size, isLandscape: true) |
|
| 273 | 258 |
} |
| 274 | 259 |
case .settings: |
| 275 |
- landscapeSettingsPage(size: size) |
|
| 260 |
+ MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
|
|
| 261 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 262 |
+ selectedMeterTab = .connection |
|
| 263 |
+ } |
|
| 264 |
+ } |
|
| 276 | 265 |
} |
| 277 | 266 |
} |
| 278 | 267 |
|
@@ -280,310 +269,28 @@ struct MeterView: View {
|
||
| 280 | 269 |
private func portraitSegmentedContent(size: CGSize) -> some View {
|
| 281 | 270 |
switch selectedMeterTab {
|
| 282 | 271 |
case .connection: |
| 283 |
- portraitConnectionPage(size: size) |
|
| 272 |
+ MeterConnectionTabView(size: size, isLandscape: false) |
|
| 284 | 273 |
case .live: |
| 285 | 274 |
if meter.operationalState == .dataIsAvailable {
|
| 286 |
- portraitLivePage(size: size) |
|
| 275 |
+ MeterLiveTabView(size: size, isLandscape: false) |
|
| 287 | 276 |
} else {
|
| 288 |
- portraitConnectionPage(size: size) |
|
| 277 |
+ MeterConnectionTabView(size: size, isLandscape: false) |
|
| 289 | 278 |
} |
| 290 | 279 |
case .chart: |
| 291 | 280 |
if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
|
| 292 |
- portraitChartPage |
|
| 281 |
+ MeterChartTabView(size: size, isLandscape: false) |
|
| 293 | 282 |
} else {
|
| 294 |
- portraitConnectionPage(size: size) |
|
| 283 |
+ MeterConnectionTabView(size: size, isLandscape: false) |
|
| 295 | 284 |
} |
| 296 | 285 |
case .settings: |
| 297 |
- portraitSettingsPage(size: size) |
|
| 298 |
- } |
|
| 299 |
- } |
|
| 300 |
- |
|
| 301 |
- private func portraitConnectionPage(size: CGSize) -> some View {
|
|
| 302 |
- portraitFace {
|
|
| 303 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 304 |
- connectionCard( |
|
| 305 |
- compact: prefersCompactPortraitConnection(for: size), |
|
| 306 |
- showsActions: meter.operationalState == .dataIsAvailable |
|
| 307 |
- ) |
|
| 308 |
- |
|
| 309 |
- homeInfoPreview |
|
| 310 |
- } |
|
| 311 |
- } |
|
| 312 |
- } |
|
| 313 |
- |
|
| 314 |
- private func portraitLivePage(size: CGSize) -> some View {
|
|
| 315 |
- portraitFace {
|
|
| 316 |
- LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size) |
|
| 317 |
- .padding(contentCardPadding) |
|
| 318 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 319 |
- } |
|
| 320 |
- } |
|
| 321 |
- |
|
| 322 |
- private var portraitChartPage: some View {
|
|
| 323 |
- portraitFace {
|
|
| 324 |
- MeasurementChartView() |
|
| 325 |
- .environmentObject(meter.measurements) |
|
| 326 |
- .frame(minHeight: myBounds.height / 3.4) |
|
| 327 |
- .padding(contentCardPadding) |
|
| 328 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 329 |
- } |
|
| 330 |
- } |
|
| 331 |
- |
|
| 332 |
- private var landscapeConnectionPage: some View {
|
|
| 333 |
- landscapeFace {
|
|
| 334 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 335 |
- connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable) |
|
| 336 |
- |
|
| 337 |
- homeInfoPreview |
|
| 338 |
- } |
|
| 339 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 340 |
- } |
|
| 341 |
- } |
|
| 342 |
- |
|
| 343 |
- private var homeInfoPreview: some View {
|
|
| 344 |
- VStack(spacing: 14) {
|
|
| 345 |
- MeterInfoCard(title: "Overview", tint: meter.color) {
|
|
| 346 |
- MeterInfoRow(label: "Name", value: meter.name) |
|
| 347 |
- MeterInfoRow(label: "Device Model", value: meter.deviceModelName) |
|
| 348 |
- MeterInfoRow(label: "Advertised Model", value: meter.modelString) |
|
| 349 |
- MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage) |
|
| 350 |
- MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription) |
|
| 351 |
- MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen)) |
|
| 352 |
- MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt)) |
|
| 353 |
- } |
|
| 354 |
- |
|
| 355 |
- MeterInfoCard(title: "Identifiers", tint: .blue) {
|
|
| 356 |
- MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description) |
|
| 357 |
- if meter.modelNumber != 0 {
|
|
| 358 |
- MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)") |
|
| 359 |
- } |
|
| 360 |
- } |
|
| 361 |
- |
|
| 362 |
- MeterInfoCard(title: "Screen Reporting", tint: .orange) {
|
|
| 363 |
- if meter.reportsCurrentScreenIndex {
|
|
| 364 |
- MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription) |
|
| 365 |
- Text("The active screen index is reported by the meter and mapped by the app to a known label.")
|
|
| 366 |
- .font(.footnote) |
|
| 367 |
- .foregroundColor(.secondary) |
|
| 368 |
- } else {
|
|
| 369 |
- MeterInfoRow(label: "Current Screen", value: "Not Reported") |
|
| 370 |
- Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
|
|
| 371 |
- .font(.footnote) |
|
| 372 |
- .foregroundColor(.secondary) |
|
| 286 |
+ MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
|
|
| 287 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 288 |
+ selectedMeterTab = .connection |
|
| 373 | 289 |
} |
| 374 | 290 |
} |
| 375 |
- |
|
| 376 |
- MeterInfoCard(title: "Live Device Details", tint: .indigo) {
|
|
| 377 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 378 |
- if !meter.firmwareVersion.isEmpty {
|
|
| 379 |
- MeterInfoRow(label: "Firmware", value: meter.firmwareVersion) |
|
| 380 |
- } |
|
| 381 |
- if meter.supportsChargerDetection {
|
|
| 382 |
- MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription) |
|
| 383 |
- } |
|
| 384 |
- if meter.serialNumber != 0 {
|
|
| 385 |
- MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)") |
|
| 386 |
- } |
|
| 387 |
- if meter.bootCount != 0 {
|
|
| 388 |
- MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)") |
|
| 389 |
- } |
|
| 390 |
- } else {
|
|
| 391 |
- Text("Connect to the meter to load firmware, serial, and boot details.")
|
|
| 392 |
- .font(.footnote) |
|
| 393 |
- .foregroundColor(.secondary) |
|
| 394 |
- } |
|
| 395 |
- } |
|
| 396 |
- } |
|
| 397 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 398 |
- } |
|
| 399 |
- |
|
| 400 |
- private func landscapeLivePage(size: CGSize) -> some View {
|
|
| 401 |
- landscapeFace {
|
|
| 402 |
- LiveView(compactLayout: true, availableSize: size) |
|
| 403 |
- .padding(contentCardPadding) |
|
| 404 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 405 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 406 | 291 |
} |
| 407 | 292 |
} |
| 408 | 293 |
|
| 409 |
- private func landscapeChartPage(size: CGSize) -> some View {
|
|
| 410 |
- landscapeFace {
|
|
| 411 |
- MeasurementChartView() |
|
| 412 |
- .environmentObject(meter.measurements) |
|
| 413 |
- .frame(height: max(250, size.height - 44)) |
|
| 414 |
- .padding(contentCardPadding) |
|
| 415 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) |
|
| 416 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 417 |
- } |
|
| 418 |
- } |
|
| 419 |
- |
|
| 420 |
- private func meterHistoryText(for date: Date?) -> String {
|
|
| 421 |
- guard let date else {
|
|
| 422 |
- return "Never" |
|
| 423 |
- } |
|
| 424 |
- return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 425 |
- } |
|
| 426 |
- |
|
| 427 |
- @ViewBuilder |
|
| 428 |
- private func portraitSettingsPage(size: CGSize) -> some View {
|
|
| 429 |
- settingsTabContent |
|
| 430 |
- } |
|
| 431 |
- |
|
| 432 |
- @ViewBuilder |
|
| 433 |
- private func landscapeSettingsPage(size: CGSize) -> some View {
|
|
| 434 |
- settingsTabContent |
|
| 435 |
- } |
|
| 436 |
- |
|
| 437 |
- private var settingsTabContent: some View {
|
|
| 438 |
- VStack(spacing: 0) {
|
|
| 439 |
- if Self.isMacIPadApp {
|
|
| 440 |
- settingsMacHeader |
|
| 441 |
- } |
|
| 442 |
- ScrollView {
|
|
| 443 |
- VStack(spacing: 14) {
|
|
| 444 |
- settingsCard(title: "Name", tint: meter.color) {
|
|
| 445 |
- HStack {
|
|
| 446 |
- Spacer() |
|
| 447 |
- if !editingName {
|
|
| 448 |
- Text(meter.name) |
|
| 449 |
- .foregroundColor(.secondary) |
|
| 450 |
- } |
|
| 451 |
- ChevronView(rotate: $editingName) |
|
| 452 |
- } |
|
| 453 |
- if editingName {
|
|
| 454 |
- EditNameView(editingName: $editingName, newName: meter.name) |
|
| 455 |
- } |
|
| 456 |
- } |
|
| 457 |
- |
|
| 458 |
- if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
|
| 459 |
- settingsCard(title: "Meter Temperature Unit", tint: .orange) {
|
|
| 460 |
- Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
|
|
| 461 |
- .font(.footnote) |
|
| 462 |
- .foregroundColor(.secondary) |
|
| 463 |
- Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
|
| 464 |
- ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 465 |
- Text(unit.title).tag(unit) |
|
| 466 |
- } |
|
| 467 |
- } |
|
| 468 |
- .pickerStyle(SegmentedPickerStyle()) |
|
| 469 |
- } |
|
| 470 |
- } |
|
| 471 |
- |
|
| 472 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 473 |
- settingsCard( |
|
| 474 |
- title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls", |
|
| 475 |
- tint: .indigo |
|
| 476 |
- ) {
|
|
| 477 |
- if meter.reportsCurrentScreenIndex {
|
|
| 478 |
- Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
|
|
| 479 |
- .font(.footnote) |
|
| 480 |
- .foregroundColor(.secondary) |
|
| 481 |
- } else {
|
|
| 482 |
- Text("Use these controls when you want to switch device pages without crowding the main meter view.")
|
|
| 483 |
- .font(.footnote) |
|
| 484 |
- .foregroundColor(.secondary) |
|
| 485 |
- } |
|
| 486 |
- |
|
| 487 |
- ControlView(showsHeader: false) |
|
| 488 |
- } |
|
| 489 |
- } |
|
| 490 |
- |
|
| 491 |
- if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
|
| 492 |
- settingsCard(title: "Screen Timeout", tint: .purple) {
|
|
| 493 |
- HStack {
|
|
| 494 |
- Spacer() |
|
| 495 |
- if !editingScreenTimeout {
|
|
| 496 |
- Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off") |
|
| 497 |
- .foregroundColor(.secondary) |
|
| 498 |
- } |
|
| 499 |
- ChevronView(rotate: $editingScreenTimeout) |
|
| 500 |
- } |
|
| 501 |
- if editingScreenTimeout {
|
|
| 502 |
- EditScreenTimeoutView() |
|
| 503 |
- } |
|
| 504 |
- } |
|
| 505 |
- |
|
| 506 |
- settingsCard(title: "Screen Brightness", tint: .yellow) {
|
|
| 507 |
- HStack {
|
|
| 508 |
- Spacer() |
|
| 509 |
- if !editingScreenBrightness {
|
|
| 510 |
- Text("\(meter.screenBrightness)")
|
|
| 511 |
- .foregroundColor(.secondary) |
|
| 512 |
- } |
|
| 513 |
- ChevronView(rotate: $editingScreenBrightness) |
|
| 514 |
- } |
|
| 515 |
- if editingScreenBrightness {
|
|
| 516 |
- EditScreenBrightnessView() |
|
| 517 |
- } |
|
| 518 |
- } |
|
| 519 |
- } |
|
| 520 |
- } |
|
| 521 |
- .padding() |
|
| 522 |
- } |
|
| 523 |
- .background( |
|
| 524 |
- LinearGradient( |
|
| 525 |
- colors: [meter.color.opacity(0.14), Color.clear], |
|
| 526 |
- startPoint: .topLeading, |
|
| 527 |
- endPoint: .bottomTrailing |
|
| 528 |
- ) |
|
| 529 |
- .ignoresSafeArea() |
|
| 530 |
- ) |
|
| 531 |
- } |
|
| 532 |
- } |
|
| 533 |
- |
|
| 534 |
- private var settingsMacHeader: some View {
|
|
| 535 |
- HStack(spacing: 12) {
|
|
| 536 |
- Button {
|
|
| 537 |
- selectedMeterTab = .connection |
|
| 538 |
- } label: {
|
|
| 539 |
- HStack(spacing: 4) {
|
|
| 540 |
- Image(systemName: "chevron.left") |
|
| 541 |
- .font(.body.weight(.semibold)) |
|
| 542 |
- Text("Back")
|
|
| 543 |
- } |
|
| 544 |
- .foregroundColor(.accentColor) |
|
| 545 |
- } |
|
| 546 |
- .buttonStyle(.plain) |
|
| 547 |
- |
|
| 548 |
- Text("Meter Settings")
|
|
| 549 |
- .font(.headline) |
|
| 550 |
- .lineLimit(1) |
|
| 551 |
- |
|
| 552 |
- Spacer() |
|
| 553 |
- |
|
| 554 |
- if meter.operationalState > .notPresent {
|
|
| 555 |
- RSSIView(RSSI: meter.btSerial.averageRSSI) |
|
| 556 |
- .frame(width: 18, height: 18) |
|
| 557 |
- } |
|
| 558 |
- } |
|
| 559 |
- .padding(.horizontal, 16) |
|
| 560 |
- .padding(.vertical, 10) |
|
| 561 |
- .background( |
|
| 562 |
- Rectangle() |
|
| 563 |
- .fill(.ultraThinMaterial) |
|
| 564 |
- .ignoresSafeArea(edges: .top) |
|
| 565 |
- ) |
|
| 566 |
- .overlay(alignment: .bottom) {
|
|
| 567 |
- Rectangle() |
|
| 568 |
- .fill(Color.secondary.opacity(0.12)) |
|
| 569 |
- .frame(height: 1) |
|
| 570 |
- } |
|
| 571 |
- } |
|
| 572 |
- |
|
| 573 |
- private func settingsCard<Content: View>( |
|
| 574 |
- title: String, |
|
| 575 |
- tint: Color, |
|
| 576 |
- @ViewBuilder content: () -> Content |
|
| 577 |
- ) -> some View {
|
|
| 578 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 579 |
- Text(title) |
|
| 580 |
- .font(.headline) |
|
| 581 |
- content() |
|
| 582 |
- } |
|
| 583 |
- .padding(18) |
|
| 584 |
- .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 585 |
- } |
|
| 586 |
- |
|
| 587 | 294 |
private var availableMeterTabs: [MeterTab] {
|
| 588 | 295 |
var tabs: [MeterTab] = [.connection] |
| 589 | 296 |
|
@@ -609,27 +316,6 @@ struct MeterView: View {
|
||
| 609 | 316 |
} |
| 610 | 317 |
} |
| 611 | 318 |
|
| 612 |
- private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
|
|
| 613 |
- size.height < 760 || size.width < 380 |
|
| 614 |
- } |
|
| 615 |
- |
|
| 616 |
- private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 617 |
- ScrollView {
|
|
| 618 |
- content() |
|
| 619 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 620 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 621 |
- .padding(.vertical, pageVerticalPadding) |
|
| 622 |
- } |
|
| 623 |
- } |
|
| 624 |
- |
|
| 625 |
- private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 626 |
- content() |
|
| 627 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 628 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 629 |
- .padding(.vertical, pageVerticalPadding) |
|
| 630 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 631 |
- } |
|
| 632 |
- |
|
| 633 | 319 |
private var meterBackground: some View {
|
| 634 | 320 |
LinearGradient( |
| 635 | 321 |
colors: [ |
@@ -647,308 +333,6 @@ struct MeterView: View {
|
||
| 647 | 333 |
size.width > size.height |
| 648 | 334 |
} |
| 649 | 335 |
|
| 650 |
- private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
|
|
| 651 |
- VStack(alignment: .leading, spacing: compact ? 12 : 18) {
|
|
| 652 |
- HStack(alignment: .top) {
|
|
| 653 |
- meterIdentity(compact: compact) |
|
| 654 |
- Spacer() |
|
| 655 |
- statusBadge |
|
| 656 |
- } |
|
| 657 |
- |
|
| 658 |
- if compact {
|
|
| 659 |
- Spacer(minLength: 0) |
|
| 660 |
- } |
|
| 661 |
- |
|
| 662 |
- connectionActionArea(compact: compact) |
|
| 663 |
- |
|
| 664 |
- if showsActions {
|
|
| 665 |
- VStack(spacing: compact ? 10 : 12) {
|
|
| 666 |
- Rectangle() |
|
| 667 |
- .fill(Color.secondary.opacity(0.12)) |
|
| 668 |
- .frame(height: 1) |
|
| 669 |
- |
|
| 670 |
- actionGrid(compact: compact, embedded: true) |
|
| 671 |
- } |
|
| 672 |
- } |
|
| 673 |
- } |
|
| 674 |
- .padding(compact ? 16 : 20) |
|
| 675 |
- .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading) |
|
| 676 |
- .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24) |
|
| 677 |
- } |
|
| 678 |
- |
|
| 679 |
- private func meterIdentity(compact: Bool) -> some View {
|
|
| 680 |
- HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 681 |
- Text(meter.name) |
|
| 682 |
- .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold)) |
|
| 683 |
- .lineLimit(1) |
|
| 684 |
- .minimumScaleFactor(0.8) |
|
| 685 |
- |
|
| 686 |
- Text(meter.deviceModelName) |
|
| 687 |
- .font((compact ? Font.caption : .subheadline).weight(.semibold)) |
|
| 688 |
- .foregroundColor(.secondary) |
|
| 689 |
- .lineLimit(1) |
|
| 690 |
- .minimumScaleFactor(0.8) |
|
| 691 |
- } |
|
| 692 |
- } |
|
| 693 |
- |
|
| 694 |
- private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
|
|
| 695 |
- let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight |
|
| 696 |
- |
|
| 697 |
- return GeometryReader { proxy in
|
|
| 698 |
- let buttonWidth = actionButtonWidth(for: proxy.size.width) |
|
| 699 |
- let stripWidth = actionStripWidth(for: buttonWidth) |
|
| 700 |
- let stripContent = HStack(spacing: 0) {
|
|
| 701 |
- meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 702 |
- dataGroupsViewVisibility.toggle() |
|
| 703 |
- } |
|
| 704 |
- .sheet(isPresented: $dataGroupsViewVisibility) {
|
|
| 705 |
- DataGroupsView(visibility: $dataGroupsViewVisibility) |
|
| 706 |
- .environmentObject(meter) |
|
| 707 |
- } |
|
| 708 |
- |
|
| 709 |
- if meter.supportsRecordingView {
|
|
| 710 |
- actionStripDivider(height: currentActionHeight) |
|
| 711 |
- meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 712 |
- recordingViewVisibility.toggle() |
|
| 713 |
- } |
|
| 714 |
- .sheet(isPresented: $recordingViewVisibility) {
|
|
| 715 |
- RecordingView(visibility: $recordingViewVisibility) |
|
| 716 |
- .environmentObject(meter) |
|
| 717 |
- } |
|
| 718 |
- } |
|
| 719 |
- |
|
| 720 |
- actionStripDivider(height: currentActionHeight) |
|
| 721 |
- meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 722 |
- measurementsViewVisibility.toggle() |
|
| 723 |
- } |
|
| 724 |
- .sheet(isPresented: $measurementsViewVisibility) {
|
|
| 725 |
- MeasurementsView(visibility: $measurementsViewVisibility) |
|
| 726 |
- .environmentObject(meter.measurements) |
|
| 727 |
- } |
|
| 728 |
- } |
|
| 729 |
- .padding(actionStripPadding) |
|
| 730 |
- .frame(width: stripWidth) |
|
| 731 |
- |
|
| 732 |
- HStack {
|
|
| 733 |
- Spacer(minLength: 0) |
|
| 734 |
- stripContent |
|
| 735 |
- .meterCard( |
|
| 736 |
- tint: embedded ? meter.color : Color.secondary, |
|
| 737 |
- fillOpacity: embedded ? 0.08 : 0.10, |
|
| 738 |
- strokeOpacity: embedded ? 0.14 : 0.16, |
|
| 739 |
- cornerRadius: embedded ? 24 : 22 |
|
| 740 |
- ) |
|
| 741 |
- Spacer(minLength: 0) |
|
| 742 |
- } |
|
| 743 |
- } |
|
| 744 |
- .frame(height: currentActionHeight + (actionStripPadding * 2)) |
|
| 745 |
- } |
|
| 746 |
- |
|
| 747 |
- private func connectionActionArea(compact: Bool = false) -> some View {
|
|
| 748 |
- let connected = meter.operationalState >= .peripheralConnectionPending |
|
| 749 |
- let tint = connected ? disconnectActionTint : connectActionTint |
|
| 750 |
- |
|
| 751 |
- return Group {
|
|
| 752 |
- if meter.operationalState == .notPresent {
|
|
| 753 |
- HStack(spacing: 10) {
|
|
| 754 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 755 |
- .foregroundColor(.orange) |
|
| 756 |
- Text("Not found at this time.")
|
|
| 757 |
- .fontWeight(.semibold) |
|
| 758 |
- Spacer() |
|
| 759 |
- } |
|
| 760 |
- .padding(compact ? 12 : 16) |
|
| 761 |
- .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18) |
|
| 762 |
- } else {
|
|
| 763 |
- Button(action: {
|
|
| 764 |
- if meter.operationalState < .peripheralConnectionPending {
|
|
| 765 |
- meter.connect() |
|
| 766 |
- } else {
|
|
| 767 |
- meter.disconnect() |
|
| 768 |
- } |
|
| 769 |
- }) {
|
|
| 770 |
- HStack(spacing: 12) {
|
|
| 771 |
- Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill") |
|
| 772 |
- .foregroundColor(tint) |
|
| 773 |
- .frame(width: 30, height: 30) |
|
| 774 |
- .background(Circle().fill(tint.opacity(0.12))) |
|
| 775 |
- Text(connected ? "Disconnect" : "Connect") |
|
| 776 |
- .fontWeight(.semibold) |
|
| 777 |
- .foregroundColor(.primary) |
|
| 778 |
- Spacer() |
|
| 779 |
- } |
|
| 780 |
- .padding(.horizontal, 18) |
|
| 781 |
- .padding(.vertical, compact ? 10 : 14) |
|
| 782 |
- .frame(maxWidth: .infinity) |
|
| 783 |
- .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 784 |
- } |
|
| 785 |
- .buttonStyle(.plain) |
|
| 786 |
- } |
|
| 787 |
- } |
|
| 788 |
- } |
|
| 789 |
- |
|
| 790 |
- fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
|
|
| 791 |
- Button(action: action) {
|
|
| 792 |
- VStack(spacing: compact ? 8 : 10) {
|
|
| 793 |
- Image(systemName: icon) |
|
| 794 |
- .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 795 |
- .frame(width: compact ? 34 : 40, height: compact ? 34 : 40) |
|
| 796 |
- .background(Circle().fill(tint.opacity(0.14))) |
|
| 797 |
- Text(title) |
|
| 798 |
- .font((compact ? Font.caption : .footnote).weight(.semibold)) |
|
| 799 |
- .multilineTextAlignment(.center) |
|
| 800 |
- .lineLimit(2) |
|
| 801 |
- .minimumScaleFactor(0.9) |
|
| 802 |
- } |
|
| 803 |
- .foregroundColor(tint) |
|
| 804 |
- .frame(width: width, height: height) |
|
| 805 |
- .contentShape(Rectangle()) |
|
| 806 |
- } |
|
| 807 |
- .buttonStyle(.plain) |
|
| 808 |
- } |
|
| 809 |
- |
|
| 810 |
- private var visibleActionButtonCount: CGFloat {
|
|
| 811 |
- meter.supportsRecordingView ? 3 : 2 |
|
| 812 |
- } |
|
| 813 |
- |
|
| 814 |
- private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
|
|
| 815 |
- let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 816 |
- let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth |
|
| 817 |
- let fittedWidth = floor(contentWidth / visibleActionButtonCount) |
|
| 818 |
- return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth)) |
|
| 819 |
- } |
|
| 820 |
- |
|
| 821 |
- private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
|
|
| 822 |
- let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 823 |
- return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2) |
|
| 824 |
- } |
|
| 825 |
- |
|
| 826 |
- private func actionStripDivider(height: CGFloat) -> some View {
|
|
| 827 |
- Rectangle() |
|
| 828 |
- .fill(Color.secondary.opacity(0.16)) |
|
| 829 |
- .frame(width: actionDividerWidth, height: max(44, height - 22)) |
|
| 830 |
- } |
|
| 831 |
- |
|
| 832 |
- private var statusBadge: some View {
|
|
| 833 |
- Text(statusText) |
|
| 834 |
- .font(.caption.weight(.bold)) |
|
| 835 |
- .padding(.horizontal, 12) |
|
| 836 |
- .padding(.vertical, 6) |
|
| 837 |
- .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999) |
|
| 838 |
- } |
|
| 839 |
- |
|
| 840 |
- private var connectActionTint: Color {
|
|
| 841 |
- Color(red: 0.20, green: 0.46, blue: 0.43) |
|
| 842 |
- } |
|
| 843 |
- |
|
| 844 |
- private var disconnectActionTint: Color {
|
|
| 845 |
- Color(red: 0.66, green: 0.39, blue: 0.35) |
|
| 846 |
- } |
|
| 847 |
- |
|
| 848 |
- private var statusText: String {
|
|
| 849 |
- switch meter.operationalState {
|
|
| 850 |
- case .notPresent: |
|
| 851 |
- return "Missing" |
|
| 852 |
- case .peripheralNotConnected: |
|
| 853 |
- return "Ready" |
|
| 854 |
- case .peripheralConnectionPending: |
|
| 855 |
- return "Connecting" |
|
| 856 |
- case .peripheralConnected: |
|
| 857 |
- return "Linked" |
|
| 858 |
- case .peripheralReady: |
|
| 859 |
- return "Preparing" |
|
| 860 |
- case .comunicating: |
|
| 861 |
- return "Syncing" |
|
| 862 |
- case .dataIsAvailable: |
|
| 863 |
- return "Live" |
|
| 864 |
- } |
|
| 865 |
- } |
|
| 866 |
- |
|
| 867 |
- private var statusColor: Color {
|
|
| 868 |
- Meter.operationalColor(for: meter.operationalState) |
|
| 869 |
- } |
|
| 870 |
-} |
|
| 871 |
- |
|
| 872 |
- |
|
| 873 |
-private struct MeterInfoCard<Content: View>: View {
|
|
| 874 |
- let title: String |
|
| 875 |
- let tint: Color |
|
| 876 |
- @ViewBuilder var content: Content |
|
| 877 |
- |
|
| 878 |
- var body: some View {
|
|
| 879 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 880 |
- Text(title) |
|
| 881 |
- .font(.headline) |
|
| 882 |
- content |
|
| 883 |
- } |
|
| 884 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 885 |
- .padding(18) |
|
| 886 |
- .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 887 |
- } |
|
| 888 |
-} |
|
| 889 |
- |
|
| 890 |
-private struct MeterInfoRow: View {
|
|
| 891 |
- let label: String |
|
| 892 |
- let value: String |
|
| 893 |
- |
|
| 894 |
- var body: some View {
|
|
| 895 |
- HStack {
|
|
| 896 |
- Text(label) |
|
| 897 |
- Spacer() |
|
| 898 |
- Text(value) |
|
| 899 |
- .foregroundColor(.secondary) |
|
| 900 |
- .multilineTextAlignment(.trailing) |
|
| 901 |
- } |
|
| 902 |
- .font(.footnote) |
|
| 903 |
- } |
|
| 904 |
-} |
|
| 905 |
- |
|
| 906 |
-private struct EditNameView: View {
|
|
| 907 |
- |
|
| 908 |
- @EnvironmentObject private var meter: Meter |
|
| 909 |
- |
|
| 910 |
- @Binding var editingName: Bool |
|
| 911 |
- @State var newName: String |
|
| 912 |
- |
|
| 913 |
- var body: some View {
|
|
| 914 |
- TextField("Name", text: self.$newName, onCommit: {
|
|
| 915 |
- self.meter.name = self.newName |
|
| 916 |
- self.editingName = false |
|
| 917 |
- }) |
|
| 918 |
- .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 919 |
- .lineLimit(1) |
|
| 920 |
- .disableAutocorrection(true) |
|
| 921 |
- .multilineTextAlignment(.center) |
|
| 922 |
- } |
|
| 923 |
-} |
|
| 924 |
- |
|
| 925 |
-private struct EditScreenTimeoutView: View {
|
|
| 926 |
- |
|
| 927 |
- @EnvironmentObject private var meter: Meter |
|
| 928 |
- |
|
| 929 |
- var body: some View {
|
|
| 930 |
- Picker("", selection: self.$meter.screenTimeout ) {
|
|
| 931 |
- ForEach(1...9, id: \.self) { value in
|
|
| 932 |
- Text("\(value)").tag(value)
|
|
| 933 |
- } |
|
| 934 |
- Text("Off").tag(0)
|
|
| 935 |
- } |
|
| 936 |
- .pickerStyle(SegmentedPickerStyle()) |
|
| 937 |
- } |
|
| 938 |
-} |
|
| 939 |
- |
|
| 940 |
-private struct EditScreenBrightnessView: View {
|
|
| 941 |
- |
|
| 942 |
- @EnvironmentObject private var meter: Meter |
|
| 943 |
- |
|
| 944 |
- var body: some View {
|
|
| 945 |
- Picker("", selection: self.$meter.screenBrightness ) {
|
|
| 946 |
- ForEach(0...5, id: \.self) { value in
|
|
| 947 |
- Text("\(value)").tag(value)
|
|
| 948 |
- } |
|
| 949 |
- } |
|
| 950 |
- .pickerStyle(SegmentedPickerStyle()) |
|
| 951 |
- } |
|
| 952 | 336 |
} |
| 953 | 337 |
|
| 954 | 338 |
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac) |
@@ -0,0 +1,36 @@ |
||
| 1 |
+// |
|
| 2 |
+// ControlActionButtonView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ControlActionButtonView: View {
|
|
| 13 |
+ let title: String |
|
| 14 |
+ let symbol: String |
|
| 15 |
+ let tint: Color |
|
| 16 |
+ let compact: Bool |
|
| 17 |
+ let isExpandedCompactLayout: Bool |
|
| 18 |
+ let action: () -> Void |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ Button(action: action) {
|
|
| 22 |
+ VStack(spacing: 10) {
|
|
| 23 |
+ Image(systemName: symbol) |
|
| 24 |
+ .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 25 |
+ Text(title) |
|
| 26 |
+ .font(.footnote.weight(.semibold)) |
|
| 27 |
+ .multilineTextAlignment(.center) |
|
| 28 |
+ } |
|
| 29 |
+ .foregroundColor(tint) |
|
| 30 |
+ .frame(maxWidth: .infinity, minHeight: compact ? (isExpandedCompactLayout ? 112 : 92) : 68) |
|
| 31 |
+ .padding(.horizontal, 8) |
|
| 32 |
+ .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14) |
|
| 33 |
+ } |
|
| 34 |
+ .buttonStyle(.plain) |
|
| 35 |
+ } |
|
| 36 |
+} |
|
@@ -0,0 +1,30 @@ |
||
| 1 |
+// |
|
| 2 |
+// ControlCurrentScreenCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ControlCurrentScreenCardView: View {
|
|
| 13 |
+ let reportsCurrentScreenIndex: Bool |
|
| 14 |
+ let currentScreenDescription: String |
|
| 15 |
+ let isExpandedCompactLayout: Bool |
|
| 16 |
+ |
|
| 17 |
+ var body: some View {
|
|
| 18 |
+ if reportsCurrentScreenIndex {
|
|
| 19 |
+ Text(currentScreenDescription) |
|
| 20 |
+ .font((isExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold)) |
|
| 21 |
+ .multilineTextAlignment(.center) |
|
| 22 |
+ } else {
|
|
| 23 |
+ VStack {
|
|
| 24 |
+ Image(systemName: "questionmark.square.dashed") |
|
| 25 |
+ .font(.system(size: isExpandedCompactLayout ? 30 : 24, weight: .semibold)) |
|
| 26 |
+ .foregroundColor(.secondary) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+ } |
|
| 30 |
+} |
|
@@ -34,31 +34,41 @@ struct ControlView: View {
|
||
| 34 | 34 |
|
| 35 | 35 |
VStack(spacing: 12) {
|
| 36 | 36 |
HStack(spacing: 12) {
|
| 37 |
- controlButton( |
|
| 37 |
+ ControlActionButtonView( |
|
| 38 | 38 |
title: "Prev", |
| 39 | 39 |
symbol: "chevron.left", |
| 40 | 40 |
tint: .indigo, |
| 41 |
+ compact: true, |
|
| 42 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 41 | 43 |
action: { meter.previousScreen() }
|
| 42 | 44 |
) |
| 43 | 45 |
|
| 44 |
- currentScreenCard |
|
| 46 |
+ ControlCurrentScreenCardView( |
|
| 47 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 48 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 49 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 50 |
+ ) |
|
| 45 | 51 |
.frame(maxWidth: .infinity, minHeight: 112) |
| 46 | 52 |
.padding(.horizontal, 14) |
| 47 | 53 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 48 | 54 |
} |
| 49 | 55 |
|
| 50 | 56 |
HStack(spacing: 12) {
|
| 51 |
- controlButton( |
|
| 57 |
+ ControlActionButtonView( |
|
| 52 | 58 |
title: "Rotate", |
| 53 | 59 |
symbol: "rotate.right.fill", |
| 54 | 60 |
tint: .orange, |
| 61 |
+ compact: true, |
|
| 62 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 55 | 63 |
action: { meter.rotateScreen() }
|
| 56 | 64 |
) |
| 57 | 65 |
|
| 58 |
- controlButton( |
|
| 66 |
+ ControlActionButtonView( |
|
| 59 | 67 |
title: "Next", |
| 60 | 68 |
symbol: "chevron.right", |
| 61 | 69 |
tint: .indigo, |
| 70 |
+ compact: true, |
|
| 71 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 62 | 72 |
action: { meter.nextScreen() }
|
| 63 | 73 |
) |
| 64 | 74 |
} |
@@ -67,60 +77,79 @@ struct ControlView: View {
|
||
| 67 | 77 |
Spacer(minLength: 0) |
| 68 | 78 |
} else {
|
| 69 | 79 |
HStack(spacing: 10) {
|
| 70 |
- controlButton( |
|
| 80 |
+ ControlActionButtonView( |
|
| 71 | 81 |
title: "Prev", |
| 72 | 82 |
symbol: "chevron.left", |
| 73 | 83 |
tint: .indigo, |
| 84 |
+ compact: true, |
|
| 85 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 74 | 86 |
action: { meter.previousScreen() }
|
| 75 | 87 |
) |
| 76 | 88 |
|
| 77 |
- currentScreenCard |
|
| 89 |
+ ControlCurrentScreenCardView( |
|
| 90 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 91 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 92 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 93 |
+ ) |
|
| 78 | 94 |
.frame(maxWidth: .infinity, minHeight: 82) |
| 79 | 95 |
.padding(.horizontal, 10) |
| 80 | 96 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 81 | 97 |
|
| 82 |
- controlButton( |
|
| 98 |
+ ControlActionButtonView( |
|
| 83 | 99 |
title: "Rotate", |
| 84 | 100 |
symbol: "rotate.right.fill", |
| 85 | 101 |
tint: .orange, |
| 102 |
+ compact: true, |
|
| 103 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 86 | 104 |
action: { meter.rotateScreen() }
|
| 87 | 105 |
) |
| 88 | 106 |
|
| 89 |
- controlButton( |
|
| 107 |
+ ControlActionButtonView( |
|
| 90 | 108 |
title: "Next", |
| 91 | 109 |
symbol: "chevron.right", |
| 92 | 110 |
tint: .indigo, |
| 111 |
+ compact: true, |
|
| 112 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 93 | 113 |
action: { meter.nextScreen() }
|
| 94 | 114 |
) |
| 95 | 115 |
} |
| 96 | 116 |
} |
| 97 | 117 |
} else {
|
| 98 | 118 |
HStack(spacing: 12) {
|
| 99 |
- controlButton( |
|
| 119 |
+ ControlActionButtonView( |
|
| 100 | 120 |
title: "Prev", |
| 101 | 121 |
symbol: "chevron.left", |
| 102 | 122 |
tint: .indigo, |
| 123 |
+ compact: true, |
|
| 124 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 103 | 125 |
action: { meter.previousScreen() }
|
| 104 | 126 |
) |
| 105 | 127 |
|
| 106 |
- currentScreenCard |
|
| 128 |
+ ControlCurrentScreenCardView( |
|
| 129 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 130 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 131 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 132 |
+ ) |
|
| 107 | 133 |
.frame(maxWidth: .infinity, minHeight: 92) |
| 108 | 134 |
.padding(.horizontal, 12) |
| 109 | 135 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 110 | 136 |
|
| 111 |
- controlButton( |
|
| 137 |
+ ControlActionButtonView( |
|
| 112 | 138 |
title: "Next", |
| 113 | 139 |
symbol: "chevron.right", |
| 114 | 140 |
tint: .indigo, |
| 141 |
+ compact: true, |
|
| 142 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 115 | 143 |
action: { meter.nextScreen() }
|
| 116 | 144 |
) |
| 117 | 145 |
} |
| 118 | 146 |
|
| 119 |
- controlButton( |
|
| 147 |
+ ControlActionButtonView( |
|
| 120 | 148 |
title: "Rotate Screen", |
| 121 | 149 |
symbol: "rotate.right.fill", |
| 122 | 150 |
tint: .orange, |
| 123 | 151 |
compact: false, |
| 152 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 124 | 153 |
action: { meter.rotateScreen() }
|
| 125 | 154 |
) |
| 126 | 155 |
} |
@@ -128,47 +157,10 @@ struct ControlView: View {
|
||
| 128 | 157 |
.frame(maxWidth: .infinity, maxHeight: compactLayout ? .infinity : nil, alignment: .topLeading) |
| 129 | 158 |
} |
| 130 | 159 |
|
| 131 |
- @ViewBuilder |
|
| 132 |
- private var currentScreenCard: some View {
|
|
| 133 |
- if meter.reportsCurrentScreenIndex {
|
|
| 134 |
- Text(meter.currentScreenDescription) |
|
| 135 |
- .font((usesExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold)) |
|
| 136 |
- .multilineTextAlignment(.center) |
|
| 137 |
- } else {
|
|
| 138 |
- VStack {
|
|
| 139 |
- Image(systemName: "questionmark.square.dashed") |
|
| 140 |
- .font(.system(size: usesExpandedCompactLayout ? 30 : 24, weight: .semibold)) |
|
| 141 |
- .foregroundColor(.secondary) |
|
| 142 |
- } |
|
| 143 |
- } |
|
| 144 |
- } |
|
| 145 |
- |
|
| 146 | 160 |
private var usesExpandedCompactLayout: Bool {
|
| 147 | 161 |
compactLayout && (availableSize?.height ?? 0) >= 520 |
| 148 | 162 |
} |
| 149 | 163 |
|
| 150 |
- private func controlButton( |
|
| 151 |
- title: String, |
|
| 152 |
- symbol: String, |
|
| 153 |
- tint: Color, |
|
| 154 |
- compact: Bool = true, |
|
| 155 |
- action: @escaping () -> Void |
|
| 156 |
- ) -> some View {
|
|
| 157 |
- Button(action: action) {
|
|
| 158 |
- VStack(spacing: 10) {
|
|
| 159 |
- Image(systemName: symbol) |
|
| 160 |
- .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 161 |
- Text(title) |
|
| 162 |
- .font(.footnote.weight(.semibold)) |
|
| 163 |
- .multilineTextAlignment(.center) |
|
| 164 |
- } |
|
| 165 |
- .foregroundColor(tint) |
|
| 166 |
- .frame(maxWidth: .infinity, minHeight: compact ? (usesExpandedCompactLayout ? 112 : 92) : 68) |
|
| 167 |
- .padding(.horizontal, 8) |
|
| 168 |
- .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14) |
|
| 169 |
- } |
|
| 170 |
- .buttonStyle(.plain) |
|
| 171 |
- } |
|
| 172 | 164 |
} |
| 173 | 165 |
|
| 174 | 166 |
struct ControlView_Previews: PreviewProvider {
|
@@ -0,0 +1,13 @@ |
||
| 1 |
+// |
|
| 2 |
+// LiveMetricRange.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+ |
|
| 8 |
+struct LiveMetricRange {
|
|
| 9 |
+ let minLabel: String |
|
| 10 |
+ let maxLabel: String |
|
| 11 |
+ let minValue: String |
|
| 12 |
+ let maxValue: String |
|
| 13 |
+} |
|
@@ -0,0 +1,60 @@ |
||
| 1 |
+// |
|
| 2 |
+// LoadResistanceSymbolView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct LoadResistanceSymbolView: View {
|
|
| 9 |
+ let color: Color |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ GeometryReader { proxy in
|
|
| 13 |
+ let width = proxy.size.width |
|
| 14 |
+ let height = proxy.size.height |
|
| 15 |
+ let midY = height / 2 |
|
| 16 |
+ let startX = width * 0.10 |
|
| 17 |
+ let endX = width * 0.90 |
|
| 18 |
+ let boxMinX = width * 0.28 |
|
| 19 |
+ let boxMaxX = width * 0.72 |
|
| 20 |
+ let boxHeight = height * 0.34 |
|
| 21 |
+ let boxRect = CGRect( |
|
| 22 |
+ x: boxMinX, |
|
| 23 |
+ y: midY - (boxHeight / 2), |
|
| 24 |
+ width: boxMaxX - boxMinX, |
|
| 25 |
+ height: boxHeight |
|
| 26 |
+ ) |
|
| 27 |
+ let strokeWidth = max(1.2, height * 0.055) |
|
| 28 |
+ |
|
| 29 |
+ ZStack {
|
|
| 30 |
+ Path { path in
|
|
| 31 |
+ path.move(to: CGPoint(x: startX, y: midY)) |
|
| 32 |
+ path.addLine(to: CGPoint(x: boxRect.minX, y: midY)) |
|
| 33 |
+ path.move(to: CGPoint(x: boxRect.maxX, y: midY)) |
|
| 34 |
+ path.addLine(to: CGPoint(x: endX, y: midY)) |
|
| 35 |
+ } |
|
| 36 |
+ .stroke( |
|
| 37 |
+ color, |
|
| 38 |
+ style: StrokeStyle( |
|
| 39 |
+ lineWidth: strokeWidth, |
|
| 40 |
+ lineCap: .round, |
|
| 41 |
+ lineJoin: .round |
|
| 42 |
+ ) |
|
| 43 |
+ ) |
|
| 44 |
+ |
|
| 45 |
+ Path { path in
|
|
| 46 |
+ path.addRect(boxRect) |
|
| 47 |
+ } |
|
| 48 |
+ .stroke( |
|
| 49 |
+ color, |
|
| 50 |
+ style: StrokeStyle( |
|
| 51 |
+ lineWidth: strokeWidth, |
|
| 52 |
+ lineCap: .round, |
|
| 53 |
+ lineJoin: .round |
|
| 54 |
+ ) |
|
| 55 |
+ ) |
|
| 56 |
+ } |
|
| 57 |
+ } |
|
| 58 |
+ .padding(4) |
|
| 59 |
+ } |
|
| 60 |
+} |
|
@@ -9,67 +9,6 @@ |
||
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 | 11 |
struct LiveView: View {
|
| 12 |
- private struct MetricRange {
|
|
| 13 |
- let minLabel: String |
|
| 14 |
- let maxLabel: String |
|
| 15 |
- let minValue: String |
|
| 16 |
- let maxValue: String |
|
| 17 |
- } |
|
| 18 |
- |
|
| 19 |
- private struct LoadResistanceSymbol: View {
|
|
| 20 |
- let color: Color |
|
| 21 |
- |
|
| 22 |
- var body: some View {
|
|
| 23 |
- GeometryReader { proxy in
|
|
| 24 |
- let width = proxy.size.width |
|
| 25 |
- let height = proxy.size.height |
|
| 26 |
- let midY = height / 2 |
|
| 27 |
- let startX = width * 0.10 |
|
| 28 |
- let endX = width * 0.90 |
|
| 29 |
- let boxMinX = width * 0.28 |
|
| 30 |
- let boxMaxX = width * 0.72 |
|
| 31 |
- let boxHeight = height * 0.34 |
|
| 32 |
- let boxRect = CGRect( |
|
| 33 |
- x: boxMinX, |
|
| 34 |
- y: midY - (boxHeight / 2), |
|
| 35 |
- width: boxMaxX - boxMinX, |
|
| 36 |
- height: boxHeight |
|
| 37 |
- ) |
|
| 38 |
- let strokeWidth = max(1.2, height * 0.055) |
|
| 39 |
- |
|
| 40 |
- ZStack {
|
|
| 41 |
- Path { path in
|
|
| 42 |
- path.move(to: CGPoint(x: startX, y: midY)) |
|
| 43 |
- path.addLine(to: CGPoint(x: boxRect.minX, y: midY)) |
|
| 44 |
- path.move(to: CGPoint(x: boxRect.maxX, y: midY)) |
|
| 45 |
- path.addLine(to: CGPoint(x: endX, y: midY)) |
|
| 46 |
- } |
|
| 47 |
- .stroke( |
|
| 48 |
- color, |
|
| 49 |
- style: StrokeStyle( |
|
| 50 |
- lineWidth: strokeWidth, |
|
| 51 |
- lineCap: .round, |
|
| 52 |
- lineJoin: .round |
|
| 53 |
- ) |
|
| 54 |
- ) |
|
| 55 |
- |
|
| 56 |
- Path { path in
|
|
| 57 |
- path.addRect(boxRect) |
|
| 58 |
- } |
|
| 59 |
- .stroke( |
|
| 60 |
- color, |
|
| 61 |
- style: StrokeStyle( |
|
| 62 |
- lineWidth: strokeWidth, |
|
| 63 |
- lineCap: .round, |
|
| 64 |
- lineJoin: .round |
|
| 65 |
- ) |
|
| 66 |
- ) |
|
| 67 |
- } |
|
| 68 |
- } |
|
| 69 |
- .padding(4) |
|
| 70 |
- } |
|
| 71 |
- } |
|
| 72 |
- |
|
| 73 | 12 |
@EnvironmentObject private var meter: Meter |
| 74 | 13 |
var compactLayout: Bool = false |
| 75 | 14 |
var availableSize: CGSize? = nil |
@@ -130,7 +69,7 @@ struct LiveView: View {
|
||
| 130 | 69 |
|
| 131 | 70 |
liveMetricCard( |
| 132 | 71 |
title: "Load", |
| 133 |
- customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)), |
|
| 72 |
+ customSymbol: AnyView(LoadResistanceSymbolView(color: .yellow)), |
|
| 134 | 73 |
color: .yellow, |
| 135 | 74 |
value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
| 136 | 75 |
detailText: "Measured resistance" |
@@ -141,7 +80,7 @@ struct LiveView: View {
|
||
| 141 | 80 |
symbol: "dot.radiowaves.left.and.right", |
| 142 | 81 |
color: .mint, |
| 143 | 82 |
value: "\(meter.btSerial.averageRSSI) dBm", |
| 144 |
- range: MetricRange( |
|
| 83 |
+ range: LiveMetricRange( |
|
| 145 | 84 |
minLabel: "Min", |
| 146 | 85 |
maxLabel: "Max", |
| 147 | 86 |
minValue: "\(meter.btSerial.minRSSI) dBm", |
@@ -190,7 +129,7 @@ struct LiveView: View {
|
||
| 190 | 129 |
customSymbol: AnyView? = nil, |
| 191 | 130 |
color: Color, |
| 192 | 131 |
value: String, |
| 193 |
- range: MetricRange? = nil, |
|
| 132 |
+ range: LiveMetricRange? = nil, |
|
| 194 | 133 |
detailText: String? = nil, |
| 195 | 134 |
valueFont: Font? = nil, |
| 196 | 135 |
valueLineLimit: Int = 1, |
@@ -251,7 +190,7 @@ struct LiveView: View {
|
||
| 251 | 190 |
.meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12) |
| 252 | 191 |
} |
| 253 | 192 |
|
| 254 |
- private func metricRangeTable(_ range: MetricRange) -> some View {
|
|
| 193 |
+ private func metricRangeTable(_ range: LiveMetricRange) -> some View {
|
|
| 255 | 194 |
VStack(alignment: .leading, spacing: 4) {
|
| 256 | 195 |
HStack(spacing: 12) {
|
| 257 | 196 |
Text(range.minLabel) |
@@ -273,10 +212,10 @@ struct LiveView: View {
|
||
| 273 | 212 |
} |
| 274 | 213 |
} |
| 275 | 214 |
|
| 276 |
- private func metricRange(min: Double, max: Double, unit: String) -> MetricRange? {
|
|
| 215 |
+ private func metricRange(min: Double, max: Double, unit: String) -> LiveMetricRange? {
|
|
| 277 | 216 |
guard min.isFinite, max.isFinite else { return nil }
|
| 278 | 217 |
|
| 279 |
- return MetricRange( |
|
| 218 |
+ return LiveMetricRange( |
|
| 280 | 219 |
minLabel: "Min", |
| 281 | 220 |
maxLabel: "Max", |
| 282 | 221 |
minValue: "\(min.format(decimalDigits: 3)) \(unit)", |
@@ -284,11 +223,11 @@ struct LiveView: View {
|
||
| 284 | 223 |
) |
| 285 | 224 |
} |
| 286 | 225 |
|
| 287 |
- private func temperatureRange() -> MetricRange? {
|
|
| 226 |
+ private func temperatureRange() -> LiveMetricRange? {
|
|
| 288 | 227 |
let value = meter.primaryTemperatureDescription |
| 289 | 228 |
guard !value.isEmpty else { return nil }
|
| 290 | 229 |
|
| 291 |
- return MetricRange( |
|
| 230 |
+ return LiveMetricRange( |
|
| 292 | 231 |
minLabel: "Min", |
| 293 | 232 |
maxLabel: "Max", |
| 294 | 233 |
minValue: value, |
@@ -0,0 +1,33 @@ |
||
| 1 |
+// |
|
| 2 |
+// RecordingMetricsTableView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct RecordingMetricsTableView: View {
|
|
| 13 |
+ let labels: [String] |
|
| 14 |
+ let values: [String] |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ HStack(alignment: .top) {
|
|
| 18 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 19 |
+ ForEach(labels, id: \.self) { label in
|
|
| 20 |
+ Text(label) |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ Spacer() |
|
| 24 |
+ VStack(alignment: .trailing, spacing: 10) {
|
|
| 25 |
+ ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
| 26 |
+ Text(value) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+ .monospacedDigit() |
|
| 30 |
+ } |
|
| 31 |
+ .font(.footnote.weight(.semibold)) |
|
| 32 |
+ } |
|
| 33 |
+} |
|
@@ -42,23 +42,15 @@ struct RecordingView: View {
|
||
| 42 | 42 |
.padding(18) |
| 43 | 43 |
.meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) |
| 44 | 44 |
|
| 45 |
- HStack(alignment: .top) {
|
|
| 46 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 47 |
- Text("Capacity")
|
|
| 48 |
- Text("Energy")
|
|
| 49 |
- Text("Duration")
|
|
| 50 |
- Text("Stop Threshold")
|
|
| 51 |
- } |
|
| 52 |
- Spacer() |
|
| 53 |
- VStack(alignment: .trailing, spacing: 10) {
|
|
| 54 |
- Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
|
|
| 55 |
- Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
|
|
| 56 |
- Text(usbMeter.chargeRecordDurationDescription) |
|
| 57 |
- Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
|
|
| 58 |
- } |
|
| 59 |
- .monospacedDigit() |
|
| 60 |
- } |
|
| 61 |
- .font(.footnote.weight(.semibold)) |
|
| 45 |
+ RecordingMetricsTableView( |
|
| 46 |
+ labels: ["Capacity", "Energy", "Duration", "Stop Threshold"], |
|
| 47 |
+ values: [ |
|
| 48 |
+ "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah", |
|
| 49 |
+ "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh", |
|
| 50 |
+ usbMeter.chargeRecordDurationDescription, |
|
| 51 |
+ "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A" |
|
| 52 |
+ ] |
|
| 53 |
+ ) |
|
| 62 | 54 |
.padding(18) |
| 63 | 55 |
.meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20) |
| 64 | 56 |
|
@@ -106,27 +98,15 @@ struct RecordingView: View {
|
||
| 106 | 98 |
VStack(alignment: .leading, spacing: 12) {
|
| 107 | 99 |
Text("Meter Totals")
|
| 108 | 100 |
.font(.headline) |
| 109 |
- HStack(alignment: .top) {
|
|
| 110 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 111 |
- Text("Capacity")
|
|
| 112 |
- Text("Energy")
|
|
| 113 |
- Text("Duration")
|
|
| 114 |
- Text("Meter Threshold")
|
|
| 115 |
- } |
|
| 116 |
- Spacer() |
|
| 117 |
- VStack(alignment: .trailing, spacing: 10) {
|
|
| 118 |
- Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
|
|
| 119 |
- Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
|
|
| 120 |
- Text(usbMeter.recordingDurationDescription) |
|
| 121 |
- if usbMeter.supportsRecordingThreshold {
|
|
| 122 |
- Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 123 |
- } else {
|
|
| 124 |
- Text("Read-only")
|
|
| 125 |
- } |
|
| 126 |
- } |
|
| 127 |
- .monospacedDigit() |
|
| 128 |
- } |
|
| 129 |
- .font(.footnote.weight(.semibold)) |
|
| 101 |
+ RecordingMetricsTableView( |
|
| 102 |
+ labels: ["Capacity", "Energy", "Duration", "Meter Threshold"], |
|
| 103 |
+ values: [ |
|
| 104 |
+ "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah", |
|
| 105 |
+ "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh", |
|
| 106 |
+ usbMeter.recordingDurationDescription, |
|
| 107 |
+ usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only" |
|
| 108 |
+ ] |
|
| 109 |
+ ) |
|
| 130 | 110 |
Text("These values are reported by the meter for the active data group.")
|
| 131 | 111 |
.font(.footnote) |
| 132 | 112 |
.foregroundColor(.secondary) |
@@ -0,0 +1,57 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterChartTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterChartTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ let size: CGSize |
|
| 12 |
+ let isLandscape: Bool |
|
| 13 |
+ |
|
| 14 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 15 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 16 |
+ private let contentCardPadding: CGFloat = 16 |
|
| 17 |
+ |
|
| 18 |
+ var body: some View {
|
|
| 19 |
+ Group {
|
|
| 20 |
+ if isLandscape {
|
|
| 21 |
+ landscapeFace {
|
|
| 22 |
+ MeasurementChartView() |
|
| 23 |
+ .environmentObject(meter.measurements) |
|
| 24 |
+ .frame(height: max(250, size.height - 44)) |
|
| 25 |
+ .padding(contentCardPadding) |
|
| 26 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) |
|
| 27 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 28 |
+ } |
|
| 29 |
+ } else {
|
|
| 30 |
+ portraitFace {
|
|
| 31 |
+ MeasurementChartView() |
|
| 32 |
+ .environmentObject(meter.measurements) |
|
| 33 |
+ .frame(minHeight: size.height / 3.4) |
|
| 34 |
+ .padding(contentCardPadding) |
|
| 35 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 42 |
+ ScrollView {
|
|
| 43 |
+ content() |
|
| 44 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 45 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 46 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 51 |
+ content() |
|
| 52 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 53 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 54 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 55 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 56 |
+ } |
|
| 57 |
+} |
|
@@ -0,0 +1,78 @@ |
||
| 1 |
+// |
|
| 2 |
+// ConnectionHomeInfoPreviewView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ConnectionHomeInfoPreviewView: View {
|
|
| 13 |
+ let meter: Meter |
|
| 14 |
+ |
|
| 15 |
+ var body: some View {
|
|
| 16 |
+ VStack(spacing: 14) {
|
|
| 17 |
+ MeterInfoCard(title: "Overview", tint: meter.color) {
|
|
| 18 |
+ MeterInfoRow(label: "Name", value: meter.name) |
|
| 19 |
+ MeterInfoRow(label: "Device Model", value: meter.deviceModelName) |
|
| 20 |
+ MeterInfoRow(label: "Advertised Model", value: meter.modelString) |
|
| 21 |
+ MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage) |
|
| 22 |
+ MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription) |
|
| 23 |
+ MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen)) |
|
| 24 |
+ MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt)) |
|
| 25 |
+ } |
|
| 26 |
+ |
|
| 27 |
+ MeterInfoCard(title: "Identifiers", tint: .blue) {
|
|
| 28 |
+ MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description) |
|
| 29 |
+ if meter.modelNumber != 0 {
|
|
| 30 |
+ MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)") |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ MeterInfoCard(title: "Screen Reporting", tint: .orange) {
|
|
| 35 |
+ if meter.reportsCurrentScreenIndex {
|
|
| 36 |
+ MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription) |
|
| 37 |
+ Text("The active screen index is reported by the meter and mapped by the app to a known label.")
|
|
| 38 |
+ .font(.footnote) |
|
| 39 |
+ .foregroundColor(.secondary) |
|
| 40 |
+ } else {
|
|
| 41 |
+ MeterInfoRow(label: "Current Screen", value: "Not Reported") |
|
| 42 |
+ Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
|
|
| 43 |
+ .font(.footnote) |
|
| 44 |
+ .foregroundColor(.secondary) |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ |
|
| 48 |
+ MeterInfoCard(title: "Live Device Details", tint: .indigo) {
|
|
| 49 |
+ if meter.operationalState == .dataIsAvailable {
|
|
| 50 |
+ if !meter.firmwareVersion.isEmpty {
|
|
| 51 |
+ MeterInfoRow(label: "Firmware", value: meter.firmwareVersion) |
|
| 52 |
+ } |
|
| 53 |
+ if meter.supportsChargerDetection {
|
|
| 54 |
+ MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription) |
|
| 55 |
+ } |
|
| 56 |
+ if meter.serialNumber != 0 {
|
|
| 57 |
+ MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)") |
|
| 58 |
+ } |
|
| 59 |
+ if meter.bootCount != 0 {
|
|
| 60 |
+ MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)") |
|
| 61 |
+ } |
|
| 62 |
+ } else {
|
|
| 63 |
+ Text("Connect to the meter to load firmware, serial, and boot details.")
|
|
| 64 |
+ .font(.footnote) |
|
| 65 |
+ .foregroundColor(.secondary) |
|
| 66 |
+ } |
|
| 67 |
+ } |
|
| 68 |
+ } |
|
| 69 |
+ .padding(.horizontal, 12) |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ private func meterHistoryText(for date: Date?) -> String {
|
|
| 73 |
+ guard let date else {
|
|
| 74 |
+ return "Never" |
|
| 75 |
+ } |
|
| 76 |
+ return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 77 |
+ } |
|
| 78 |
+} |
|
@@ -0,0 +1,65 @@ |
||
| 1 |
+// |
|
| 2 |
+// ConnectionPrimaryActionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ConnectionPrimaryActionView: View {
|
|
| 13 |
+ let operationalState: Meter.OperationalState |
|
| 14 |
+ let compact: Bool |
|
| 15 |
+ let connectAction: () -> Void |
|
| 16 |
+ let disconnectAction: () -> Void |
|
| 17 |
+ |
|
| 18 |
+ private var connected: Bool {
|
|
| 19 |
+ operationalState >= .peripheralConnectionPending |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ private var actionTint: Color {
|
|
| 23 |
+ connected ? Color(red: 0.66, green: 0.39, blue: 0.35) : Color(red: 0.20, green: 0.46, blue: 0.43) |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ var body: some View {
|
|
| 27 |
+ Group {
|
|
| 28 |
+ if operationalState == .notPresent {
|
|
| 29 |
+ HStack(spacing: 10) {
|
|
| 30 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 31 |
+ .foregroundColor(.orange) |
|
| 32 |
+ Text("Not found at this time.")
|
|
| 33 |
+ .fontWeight(.semibold) |
|
| 34 |
+ Spacer() |
|
| 35 |
+ } |
|
| 36 |
+ .padding(compact ? 12 : 16) |
|
| 37 |
+ .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18) |
|
| 38 |
+ } else {
|
|
| 39 |
+ Button(action: {
|
|
| 40 |
+ if connected {
|
|
| 41 |
+ disconnectAction() |
|
| 42 |
+ } else {
|
|
| 43 |
+ connectAction() |
|
| 44 |
+ } |
|
| 45 |
+ }) {
|
|
| 46 |
+ HStack(spacing: 12) {
|
|
| 47 |
+ Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill") |
|
| 48 |
+ .foregroundColor(actionTint) |
|
| 49 |
+ .frame(width: 30, height: 30) |
|
| 50 |
+ .background(Circle().fill(actionTint.opacity(0.12))) |
|
| 51 |
+ Text(connected ? "Disconnect" : "Connect") |
|
| 52 |
+ .fontWeight(.semibold) |
|
| 53 |
+ .foregroundColor(.primary) |
|
| 54 |
+ Spacer() |
|
| 55 |
+ } |
|
| 56 |
+ .padding(.horizontal, 18) |
|
| 57 |
+ .padding(.vertical, compact ? 10 : 14) |
|
| 58 |
+ .frame(maxWidth: .infinity) |
|
| 59 |
+ .meterCard(tint: actionTint, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 60 |
+ } |
|
| 61 |
+ .buttonStyle(.plain) |
|
| 62 |
+ } |
|
| 63 |
+ } |
|
| 64 |
+ } |
|
| 65 |
+} |
|
@@ -0,0 +1,23 @@ |
||
| 1 |
+// |
|
| 2 |
+// ConnectionStatusBadgeView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ConnectionStatusBadgeView: View {
|
|
| 13 |
+ let text: String |
|
| 14 |
+ let color: Color |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ Text(text) |
|
| 18 |
+ .font(.caption.weight(.bold)) |
|
| 19 |
+ .padding(.horizontal, 12) |
|
| 20 |
+ .padding(.vertical, 6) |
|
| 21 |
+ .meterCard(tint: color, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999) |
|
| 22 |
+ } |
|
| 23 |
+} |
|
@@ -0,0 +1,23 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterInfoCard.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterInfoCard<Content: View>: View {
|
|
| 9 |
+ let title: String |
|
| 10 |
+ let tint: Color |
|
| 11 |
+ @ViewBuilder var content: Content |
|
| 12 |
+ |
|
| 13 |
+ var body: some View {
|
|
| 14 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 15 |
+ Text(title) |
|
| 16 |
+ .font(.headline) |
|
| 17 |
+ content |
|
| 18 |
+ } |
|
| 19 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 20 |
+ .padding(18) |
|
| 21 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 22 |
+ } |
|
| 23 |
+} |
|
@@ -0,0 +1,22 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterInfoRow.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterInfoRow: View {
|
|
| 9 |
+ let label: String |
|
| 10 |
+ let value: String |
|
| 11 |
+ |
|
| 12 |
+ var body: some View {
|
|
| 13 |
+ HStack {
|
|
| 14 |
+ Text(label) |
|
| 15 |
+ Spacer() |
|
| 16 |
+ Text(value) |
|
| 17 |
+ .foregroundColor(.secondary) |
|
| 18 |
+ .multilineTextAlignment(.trailing) |
|
| 19 |
+ } |
|
| 20 |
+ .font(.footnote) |
|
| 21 |
+ } |
|
| 22 |
+} |
|
@@ -0,0 +1,245 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterConnectionTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterConnectionTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ let size: CGSize |
|
| 12 |
+ let isLandscape: Bool |
|
| 13 |
+ |
|
| 14 |
+ @State private var dataGroupsViewVisibility = false |
|
| 15 |
+ @State private var recordingViewVisibility = false |
|
| 16 |
+ @State private var measurementsViewVisibility = false |
|
| 17 |
+ |
|
| 18 |
+ private let actionStripPadding: CGFloat = 10 |
|
| 19 |
+ private let actionDividerWidth: CGFloat = 1 |
|
| 20 |
+ private let actionButtonMaxWidth: CGFloat = 156 |
|
| 21 |
+ private let actionButtonMinWidth: CGFloat = 88 |
|
| 22 |
+ private let actionButtonHeight: CGFloat = 108 |
|
| 23 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 24 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 25 |
+ |
|
| 26 |
+ var body: some View {
|
|
| 27 |
+ Group {
|
|
| 28 |
+ if isLandscape {
|
|
| 29 |
+ landscapeFace {
|
|
| 30 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 31 |
+ connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable) |
|
| 32 |
+ ConnectionHomeInfoPreviewView(meter: meter) |
|
| 33 |
+ } |
|
| 34 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 35 |
+ } |
|
| 36 |
+ } else {
|
|
| 37 |
+ portraitFace {
|
|
| 38 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 39 |
+ connectionCard( |
|
| 40 |
+ compact: prefersCompactPortraitConnection, |
|
| 41 |
+ showsActions: meter.operationalState == .dataIsAvailable |
|
| 42 |
+ ) |
|
| 43 |
+ ConnectionHomeInfoPreviewView(meter: meter) |
|
| 44 |
+ } |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private var prefersCompactPortraitConnection: Bool {
|
|
| 51 |
+ size.height < 760 || size.width < 380 |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 55 |
+ ScrollView {
|
|
| 56 |
+ content() |
|
| 57 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 58 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 59 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 64 |
+ content() |
|
| 65 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 66 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 67 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 68 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
|
|
| 72 |
+ VStack(alignment: .leading, spacing: compact ? 12 : 18) {
|
|
| 73 |
+ HStack(alignment: .top) {
|
|
| 74 |
+ meterIdentity(compact: compact) |
|
| 75 |
+ Spacer() |
|
| 76 |
+ statusBadge |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ if compact {
|
|
| 80 |
+ Spacer(minLength: 0) |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 83 |
+ connectionActionArea(compact: compact) |
|
| 84 |
+ |
|
| 85 |
+ if showsActions {
|
|
| 86 |
+ VStack(spacing: compact ? 10 : 12) {
|
|
| 87 |
+ Rectangle() |
|
| 88 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 89 |
+ .frame(height: 1) |
|
| 90 |
+ |
|
| 91 |
+ actionGrid(compact: compact, embedded: true) |
|
| 92 |
+ } |
|
| 93 |
+ } |
|
| 94 |
+ } |
|
| 95 |
+ .padding(compact ? 16 : 20) |
|
| 96 |
+ .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading) |
|
| 97 |
+ .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24) |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ private func meterIdentity(compact: Bool) -> some View {
|
|
| 101 |
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 102 |
+ Text(meter.name) |
|
| 103 |
+ .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold)) |
|
| 104 |
+ .lineLimit(1) |
|
| 105 |
+ .minimumScaleFactor(0.8) |
|
| 106 |
+ |
|
| 107 |
+ Text(meter.deviceModelName) |
|
| 108 |
+ .font((compact ? Font.caption : .subheadline).weight(.semibold)) |
|
| 109 |
+ .foregroundColor(.secondary) |
|
| 110 |
+ .lineLimit(1) |
|
| 111 |
+ .minimumScaleFactor(0.8) |
|
| 112 |
+ } |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
|
|
| 116 |
+ let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight |
|
| 117 |
+ |
|
| 118 |
+ return GeometryReader { proxy in
|
|
| 119 |
+ let buttonWidth = actionButtonWidth(for: proxy.size.width) |
|
| 120 |
+ let stripWidth = actionStripWidth(for: buttonWidth) |
|
| 121 |
+ let stripContent = HStack(spacing: 0) {
|
|
| 122 |
+ meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 123 |
+ dataGroupsViewVisibility.toggle() |
|
| 124 |
+ } |
|
| 125 |
+ .sheet(isPresented: $dataGroupsViewVisibility) {
|
|
| 126 |
+ DataGroupsView(visibility: $dataGroupsViewVisibility) |
|
| 127 |
+ .environmentObject(meter) |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ if meter.supportsRecordingView {
|
|
| 131 |
+ actionStripDivider(height: currentActionHeight) |
|
| 132 |
+ meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 133 |
+ recordingViewVisibility.toggle() |
|
| 134 |
+ } |
|
| 135 |
+ .sheet(isPresented: $recordingViewVisibility) {
|
|
| 136 |
+ RecordingView(visibility: $recordingViewVisibility) |
|
| 137 |
+ .environmentObject(meter) |
|
| 138 |
+ } |
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 141 |
+ actionStripDivider(height: currentActionHeight) |
|
| 142 |
+ meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 143 |
+ measurementsViewVisibility.toggle() |
|
| 144 |
+ } |
|
| 145 |
+ .sheet(isPresented: $measurementsViewVisibility) {
|
|
| 146 |
+ MeasurementsView(visibility: $measurementsViewVisibility) |
|
| 147 |
+ .environmentObject(meter.measurements) |
|
| 148 |
+ } |
|
| 149 |
+ } |
|
| 150 |
+ .padding(actionStripPadding) |
|
| 151 |
+ .frame(width: stripWidth) |
|
| 152 |
+ |
|
| 153 |
+ HStack {
|
|
| 154 |
+ Spacer(minLength: 0) |
|
| 155 |
+ stripContent |
|
| 156 |
+ .meterCard( |
|
| 157 |
+ tint: embedded ? meter.color : Color.secondary, |
|
| 158 |
+ fillOpacity: embedded ? 0.08 : 0.10, |
|
| 159 |
+ strokeOpacity: embedded ? 0.14 : 0.16, |
|
| 160 |
+ cornerRadius: embedded ? 24 : 22 |
|
| 161 |
+ ) |
|
| 162 |
+ Spacer(minLength: 0) |
|
| 163 |
+ } |
|
| 164 |
+ } |
|
| 165 |
+ .frame(height: currentActionHeight + (actionStripPadding * 2)) |
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 168 |
+ private func connectionActionArea(compact: Bool = false) -> some View {
|
|
| 169 |
+ ConnectionPrimaryActionView( |
|
| 170 |
+ operationalState: meter.operationalState, |
|
| 171 |
+ compact: compact, |
|
| 172 |
+ connectAction: { meter.connect() },
|
|
| 173 |
+ disconnectAction: { meter.disconnect() }
|
|
| 174 |
+ ) |
|
| 175 |
+ } |
|
| 176 |
+ |
|
| 177 |
+ private func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
|
|
| 178 |
+ Button(action: action) {
|
|
| 179 |
+ VStack(spacing: compact ? 8 : 10) {
|
|
| 180 |
+ Image(systemName: icon) |
|
| 181 |
+ .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 182 |
+ .frame(width: compact ? 34 : 40, height: compact ? 34 : 40) |
|
| 183 |
+ .background(Circle().fill(tint.opacity(0.14))) |
|
| 184 |
+ Text(title) |
|
| 185 |
+ .font((compact ? Font.caption : .footnote).weight(.semibold)) |
|
| 186 |
+ .multilineTextAlignment(.center) |
|
| 187 |
+ .lineLimit(2) |
|
| 188 |
+ .minimumScaleFactor(0.9) |
|
| 189 |
+ } |
|
| 190 |
+ .foregroundColor(tint) |
|
| 191 |
+ .frame(width: width, height: height) |
|
| 192 |
+ .contentShape(Rectangle()) |
|
| 193 |
+ } |
|
| 194 |
+ .buttonStyle(.plain) |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ private var visibleActionButtonCount: CGFloat {
|
|
| 198 |
+ meter.supportsRecordingView ? 3 : 2 |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
|
|
| 202 |
+ let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 203 |
+ let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth |
|
| 204 |
+ let fittedWidth = floor(contentWidth / visibleActionButtonCount) |
|
| 205 |
+ return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth)) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
|
|
| 209 |
+ let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 210 |
+ return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2) |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 213 |
+ private func actionStripDivider(height: CGFloat) -> some View {
|
|
| 214 |
+ Rectangle() |
|
| 215 |
+ .fill(Color.secondary.opacity(0.16)) |
|
| 216 |
+ .frame(width: actionDividerWidth, height: max(44, height - 22)) |
|
| 217 |
+ } |
|
| 218 |
+ |
|
| 219 |
+ private var statusBadge: some View {
|
|
| 220 |
+ ConnectionStatusBadgeView(text: statusText, color: statusColor) |
|
| 221 |
+ } |
|
| 222 |
+ |
|
| 223 |
+ private var statusText: String {
|
|
| 224 |
+ switch meter.operationalState {
|
|
| 225 |
+ case .notPresent: |
|
| 226 |
+ return "Missing" |
|
| 227 |
+ case .peripheralNotConnected: |
|
| 228 |
+ return "Ready" |
|
| 229 |
+ case .peripheralConnectionPending: |
|
| 230 |
+ return "Connecting" |
|
| 231 |
+ case .peripheralConnected: |
|
| 232 |
+ return "Linked" |
|
| 233 |
+ case .peripheralReady: |
|
| 234 |
+ return "Preparing" |
|
| 235 |
+ case .comunicating: |
|
| 236 |
+ return "Syncing" |
|
| 237 |
+ case .dataIsAvailable: |
|
| 238 |
+ return "Live" |
|
| 239 |
+ } |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 242 |
+ private var statusColor: Color {
|
|
| 243 |
+ Meter.operationalColor(for: meter.operationalState) |
|
| 244 |
+ } |
|
| 245 |
+} |
|
@@ -0,0 +1,57 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterLiveTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterLiveTabView: View {
|
|
| 9 |
+ let size: CGSize |
|
| 10 |
+ let isLandscape: Bool |
|
| 11 |
+ |
|
| 12 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 13 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 14 |
+ private let contentCardPadding: CGFloat = 16 |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ Group {
|
|
| 18 |
+ if isLandscape {
|
|
| 19 |
+ landscapeFace {
|
|
| 20 |
+ LiveView(compactLayout: true, availableSize: size) |
|
| 21 |
+ .padding(contentCardPadding) |
|
| 22 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 23 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 24 |
+ } |
|
| 25 |
+ } else {
|
|
| 26 |
+ portraitFace {
|
|
| 27 |
+ LiveView(compactLayout: prefersCompactPortraitConnection, availableSize: size) |
|
| 28 |
+ .padding(contentCardPadding) |
|
| 29 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ @EnvironmentObject private var meter: Meter |
|
| 36 |
+ |
|
| 37 |
+ private var prefersCompactPortraitConnection: Bool {
|
|
| 38 |
+ size.height < 760 || size.width < 380 |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 42 |
+ ScrollView {
|
|
| 43 |
+ content() |
|
| 44 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 45 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 46 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 51 |
+ content() |
|
| 52 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 53 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 54 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 55 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 56 |
+ } |
|
| 57 |
+} |
|
@@ -0,0 +1,24 @@ |
||
| 1 |
+// |
|
| 2 |
+// EditNameView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct EditNameView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ @Binding var editingName: Bool |
|
| 12 |
+ @State var newName: String |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ TextField("Name", text: self.$newName, onCommit: {
|
|
| 16 |
+ self.meter.name = self.newName |
|
| 17 |
+ self.editingName = false |
|
| 18 |
+ }) |
|
| 19 |
+ .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 20 |
+ .lineLimit(1) |
|
| 21 |
+ .disableAutocorrection(true) |
|
| 22 |
+ .multilineTextAlignment(.center) |
|
| 23 |
+ } |
|
| 24 |
+} |
|
@@ -0,0 +1,19 @@ |
||
| 1 |
+// |
|
| 2 |
+// EditScreenBrightnessView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct EditScreenBrightnessView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ Picker("", selection: self.$meter.screenBrightness) {
|
|
| 13 |
+ ForEach(0...5, id: \.self) { value in
|
|
| 14 |
+ Text("\(value)").tag(value)
|
|
| 15 |
+ } |
|
| 16 |
+ } |
|
| 17 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 18 |
+ } |
|
| 19 |
+} |
|
@@ -0,0 +1,20 @@ |
||
| 1 |
+// |
|
| 2 |
+// EditScreenTimeoutView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct EditScreenTimeoutView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ Picker("", selection: self.$meter.screenTimeout) {
|
|
| 13 |
+ ForEach(1...9, id: \.self) { value in
|
|
| 14 |
+ Text("\(value)").tag(value)
|
|
| 15 |
+ } |
|
| 16 |
+ Text("Off").tag(0)
|
|
| 17 |
+ } |
|
| 18 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 19 |
+ } |
|
| 20 |
+} |
|
@@ -0,0 +1,165 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterSettingsTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterSettingsTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ let isMacIPadApp: Bool |
|
| 12 |
+ let onBackToHome: () -> Void |
|
| 13 |
+ |
|
| 14 |
+ @State private var editingName = false |
|
| 15 |
+ @State private var editingScreenTimeout = false |
|
| 16 |
+ @State private var editingScreenBrightness = false |
|
| 17 |
+ |
|
| 18 |
+ var body: some View {
|
|
| 19 |
+ VStack(spacing: 0) {
|
|
| 20 |
+ if isMacIPadApp {
|
|
| 21 |
+ settingsMacHeader |
|
| 22 |
+ } |
|
| 23 |
+ ScrollView {
|
|
| 24 |
+ VStack(spacing: 14) {
|
|
| 25 |
+ settingsCard(title: "Name", tint: meter.color) {
|
|
| 26 |
+ HStack {
|
|
| 27 |
+ Spacer() |
|
| 28 |
+ if !editingName {
|
|
| 29 |
+ Text(meter.name) |
|
| 30 |
+ .foregroundColor(.secondary) |
|
| 31 |
+ } |
|
| 32 |
+ ChevronView(rotate: $editingName) |
|
| 33 |
+ } |
|
| 34 |
+ if editingName {
|
|
| 35 |
+ EditNameView(editingName: $editingName, newName: meter.name) |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
|
| 40 |
+ settingsCard(title: "Meter Temperature Unit", tint: .orange) {
|
|
| 41 |
+ Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
|
|
| 42 |
+ .font(.footnote) |
|
| 43 |
+ .foregroundColor(.secondary) |
|
| 44 |
+ Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
|
| 45 |
+ ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 46 |
+ Text(unit.title).tag(unit) |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ if meter.operationalState == .dataIsAvailable {
|
|
| 54 |
+ settingsCard( |
|
| 55 |
+ title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls", |
|
| 56 |
+ tint: .indigo |
|
| 57 |
+ ) {
|
|
| 58 |
+ if meter.reportsCurrentScreenIndex {
|
|
| 59 |
+ Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
|
|
| 60 |
+ .font(.footnote) |
|
| 61 |
+ .foregroundColor(.secondary) |
|
| 62 |
+ } else {
|
|
| 63 |
+ Text("Use these controls when you want to switch device pages without crowding the main meter view.")
|
|
| 64 |
+ .font(.footnote) |
|
| 65 |
+ .foregroundColor(.secondary) |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ ControlView(showsHeader: false) |
|
| 69 |
+ } |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
|
| 73 |
+ settingsCard(title: "Screen Timeout", tint: .purple) {
|
|
| 74 |
+ HStack {
|
|
| 75 |
+ Spacer() |
|
| 76 |
+ if !editingScreenTimeout {
|
|
| 77 |
+ Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off") |
|
| 78 |
+ .foregroundColor(.secondary) |
|
| 79 |
+ } |
|
| 80 |
+ ChevronView(rotate: $editingScreenTimeout) |
|
| 81 |
+ } |
|
| 82 |
+ if editingScreenTimeout {
|
|
| 83 |
+ EditScreenTimeoutView() |
|
| 84 |
+ } |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ settingsCard(title: "Screen Brightness", tint: .yellow) {
|
|
| 88 |
+ HStack {
|
|
| 89 |
+ Spacer() |
|
| 90 |
+ if !editingScreenBrightness {
|
|
| 91 |
+ Text("\(meter.screenBrightness)")
|
|
| 92 |
+ .foregroundColor(.secondary) |
|
| 93 |
+ } |
|
| 94 |
+ ChevronView(rotate: $editingScreenBrightness) |
|
| 95 |
+ } |
|
| 96 |
+ if editingScreenBrightness {
|
|
| 97 |
+ EditScreenBrightnessView() |
|
| 98 |
+ } |
|
| 99 |
+ } |
|
| 100 |
+ } |
|
| 101 |
+ } |
|
| 102 |
+ .padding() |
|
| 103 |
+ } |
|
| 104 |
+ .background( |
|
| 105 |
+ LinearGradient( |
|
| 106 |
+ colors: [meter.color.opacity(0.14), Color.clear], |
|
| 107 |
+ startPoint: .topLeading, |
|
| 108 |
+ endPoint: .bottomTrailing |
|
| 109 |
+ ) |
|
| 110 |
+ .ignoresSafeArea() |
|
| 111 |
+ ) |
|
| 112 |
+ } |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private var settingsMacHeader: some View {
|
|
| 116 |
+ HStack(spacing: 12) {
|
|
| 117 |
+ Button(action: onBackToHome) {
|
|
| 118 |
+ HStack(spacing: 4) {
|
|
| 119 |
+ Image(systemName: "chevron.left") |
|
| 120 |
+ .font(.body.weight(.semibold)) |
|
| 121 |
+ Text("Back")
|
|
| 122 |
+ } |
|
| 123 |
+ .foregroundColor(.accentColor) |
|
| 124 |
+ } |
|
| 125 |
+ .buttonStyle(.plain) |
|
| 126 |
+ |
|
| 127 |
+ Text("Meter Settings")
|
|
| 128 |
+ .font(.headline) |
|
| 129 |
+ .lineLimit(1) |
|
| 130 |
+ |
|
| 131 |
+ Spacer() |
|
| 132 |
+ |
|
| 133 |
+ if meter.operationalState > .notPresent {
|
|
| 134 |
+ RSSIView(RSSI: meter.btSerial.averageRSSI) |
|
| 135 |
+ .frame(width: 18, height: 18) |
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ .padding(.horizontal, 16) |
|
| 139 |
+ .padding(.vertical, 10) |
|
| 140 |
+ .background( |
|
| 141 |
+ Rectangle() |
|
| 142 |
+ .fill(.ultraThinMaterial) |
|
| 143 |
+ .ignoresSafeArea(edges: .top) |
|
| 144 |
+ ) |
|
| 145 |
+ .overlay(alignment: .bottom) {
|
|
| 146 |
+ Rectangle() |
|
| 147 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 148 |
+ .frame(height: 1) |
|
| 149 |
+ } |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 152 |
+ private func settingsCard<Content: View>( |
|
| 153 |
+ title: String, |
|
| 154 |
+ tint: Color, |
|
| 155 |
+ @ViewBuilder content: () -> Content |
|
| 156 |
+ ) -> some View {
|
|
| 157 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 158 |
+ Text(title) |
|
| 159 |
+ .font(.headline) |
|
| 160 |
+ content() |
|
| 161 |
+ } |
|
| 162 |
+ .padding(18) |
|
| 163 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 164 |
+ } |
|
| 165 |
+} |
|
@@ -0,0 +1,75 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterCardView: View {
|
|
| 9 |
+ let meterSummary: AppData.MeterSummary |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ HStack(spacing: 14) {
|
|
| 13 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 14 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 15 |
+ .foregroundColor(meterSummary.tint) |
|
| 16 |
+ .frame(width: 42, height: 42) |
|
| 17 |
+ .background( |
|
| 18 |
+ Circle() |
|
| 19 |
+ .fill(meterSummary.tint.opacity(0.18)) |
|
| 20 |
+ ) |
|
| 21 |
+ .overlay(alignment: .bottomTrailing) {
|
|
| 22 |
+ Circle() |
|
| 23 |
+ .fill(Color.red) |
|
| 24 |
+ .frame(width: 12, height: 12) |
|
| 25 |
+ .overlay( |
|
| 26 |
+ Circle() |
|
| 27 |
+ .stroke(Color(uiColor: .systemBackground), lineWidth: 2) |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 32 |
+ Text(meterSummary.displayName) |
|
| 33 |
+ .font(.headline) |
|
| 34 |
+ Text(meterSummary.modelSummary) |
|
| 35 |
+ .font(.caption) |
|
| 36 |
+ .foregroundColor(.secondary) |
|
| 37 |
+ if let advertisedName = meterSummary.advertisedName, advertisedName != meterSummary.modelSummary {
|
|
| 38 |
+ Text("Advertised as \(advertisedName)")
|
|
| 39 |
+ .font(.caption2) |
|
| 40 |
+ .foregroundColor(.secondary) |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ Spacer() |
|
| 45 |
+ |
|
| 46 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 47 |
+ HStack(spacing: 6) {
|
|
| 48 |
+ Circle() |
|
| 49 |
+ .fill(Color.red) |
|
| 50 |
+ .frame(width: 8, height: 8) |
|
| 51 |
+ Text("Missing")
|
|
| 52 |
+ .font(.caption.weight(.semibold)) |
|
| 53 |
+ .foregroundColor(.secondary) |
|
| 54 |
+ } |
|
| 55 |
+ .padding(.horizontal, 10) |
|
| 56 |
+ .padding(.vertical, 6) |
|
| 57 |
+ .background( |
|
| 58 |
+ Capsule(style: .continuous) |
|
| 59 |
+ .fill(Color.red.opacity(0.12)) |
|
| 60 |
+ ) |
|
| 61 |
+ .overlay( |
|
| 62 |
+ Capsule(style: .continuous) |
|
| 63 |
+ .stroke(Color.red.opacity(0.22), lineWidth: 1) |
|
| 64 |
+ ) |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+ .padding(14) |
|
| 68 |
+ .meterCard( |
|
| 69 |
+ tint: meterSummary.tint, |
|
| 70 |
+ fillOpacity: 0.16, |
|
| 71 |
+ strokeOpacity: 0.22, |
|
| 72 |
+ cornerRadius: 18 |
|
| 73 |
+ ) |
|
| 74 |
+ } |
|
| 75 |
+} |
|
@@ -0,0 +1,39 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarLinkCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarLinkCardView: View {
|
|
| 9 |
+ let title: String |
|
| 10 |
+ let subtitle: String |
|
| 11 |
+ let symbol: String |
|
| 12 |
+ let tint: Color |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ HStack(spacing: 14) {
|
|
| 16 |
+ Image(systemName: symbol) |
|
| 17 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 18 |
+ .foregroundColor(tint) |
|
| 19 |
+ .frame(width: 42, height: 42) |
|
| 20 |
+ .background(Circle().fill(tint.opacity(0.18))) |
|
| 21 |
+ |
|
| 22 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 23 |
+ Text(title) |
|
| 24 |
+ .font(.headline) |
|
| 25 |
+ Text(subtitle) |
|
| 26 |
+ .font(.caption) |
|
| 27 |
+ .foregroundColor(.secondary) |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ Spacer() |
|
| 31 |
+ |
|
| 32 |
+ Image(systemName: "chevron.right") |
|
| 33 |
+ .font(.footnote.weight(.bold)) |
|
| 34 |
+ .foregroundColor(.secondary) |
|
| 35 |
+ } |
|
| 36 |
+ .padding(14) |
|
| 37 |
+ .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 38 |
+ } |
|
| 39 |
+} |
|
@@ -0,0 +1,63 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarAutoHelpResolver.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+import CoreBluetooth |
|
| 8 |
+ |
|
| 9 |
+enum SidebarAutoHelpResolver {
|
|
| 10 |
+ static func activeReason( |
|
| 11 |
+ managerState: CBManagerState, |
|
| 12 |
+ cloudAvailability: MeterNameStore.CloudAvailability, |
|
| 13 |
+ hasLiveMeters: Bool, |
|
| 14 |
+ scanStartedAt: Date?, |
|
| 15 |
+ now: Date, |
|
| 16 |
+ noDevicesHelpDelay: TimeInterval |
|
| 17 |
+ ) -> SidebarHelpReason? {
|
|
| 18 |
+ if managerState == .unauthorized {
|
|
| 19 |
+ return .bluetoothPermission |
|
| 20 |
+ } |
|
| 21 |
+ if shouldPromptForCloudSync(cloudAvailability) {
|
|
| 22 |
+ return .cloudSyncUnavailable |
|
| 23 |
+ } |
|
| 24 |
+ if hasWaitedLongEnoughForDevices( |
|
| 25 |
+ managerState: managerState, |
|
| 26 |
+ hasLiveMeters: hasLiveMeters, |
|
| 27 |
+ scanStartedAt: scanStartedAt, |
|
| 28 |
+ now: now, |
|
| 29 |
+ noDevicesHelpDelay: noDevicesHelpDelay |
|
| 30 |
+ ) {
|
|
| 31 |
+ return .noDevicesDetected |
|
| 32 |
+ } |
|
| 33 |
+ return nil |
|
| 34 |
+ } |
|
| 35 |
+ |
|
| 36 |
+ private static func shouldPromptForCloudSync(_ cloudAvailability: MeterNameStore.CloudAvailability) -> Bool {
|
|
| 37 |
+ switch cloudAvailability {
|
|
| 38 |
+ case .noAccount, .error: |
|
| 39 |
+ return true |
|
| 40 |
+ case .unknown, .available: |
|
| 41 |
+ return false |
|
| 42 |
+ } |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private static func hasWaitedLongEnoughForDevices( |
|
| 46 |
+ managerState: CBManagerState, |
|
| 47 |
+ hasLiveMeters: Bool, |
|
| 48 |
+ scanStartedAt: Date?, |
|
| 49 |
+ now: Date, |
|
| 50 |
+ noDevicesHelpDelay: TimeInterval |
|
| 51 |
+ ) -> Bool {
|
|
| 52 |
+ guard managerState == .poweredOn else {
|
|
| 53 |
+ return false |
|
| 54 |
+ } |
|
| 55 |
+ guard hasLiveMeters == false else {
|
|
| 56 |
+ return false |
|
| 57 |
+ } |
|
| 58 |
+ guard let scanStartedAt else {
|
|
| 59 |
+ return false |
|
| 60 |
+ } |
|
| 61 |
+ return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay |
|
| 62 |
+ } |
|
| 63 |
+} |
|
@@ -0,0 +1,141 @@ |
||
| 1 |
+// |
|
| 2 |
+// ContentSidebarHelpSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ContentSidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
|
|
| 9 |
+ let activeReason: SidebarHelpReason? |
|
| 10 |
+ let isExpanded: Bool |
|
| 11 |
+ let bluetoothStatusTint: Color |
|
| 12 |
+ let bluetoothStatusText: String |
|
| 13 |
+ let cloudSyncHelpTitle: String |
|
| 14 |
+ let cloudSyncHelpMessage: String |
|
| 15 |
+ let onToggle: () -> Void |
|
| 16 |
+ let onOpenSettings: () -> Void |
|
| 17 |
+ let bluetoothHelpDestination: BluetoothHelpDestination |
|
| 18 |
+ let deviceHelpDestination: DeviceHelpDestination |
|
| 19 |
+ |
|
| 20 |
+ init( |
|
| 21 |
+ activeReason: SidebarHelpReason?, |
|
| 22 |
+ isExpanded: Bool, |
|
| 23 |
+ bluetoothStatusTint: Color, |
|
| 24 |
+ bluetoothStatusText: String, |
|
| 25 |
+ cloudSyncHelpTitle: String, |
|
| 26 |
+ cloudSyncHelpMessage: String, |
|
| 27 |
+ onToggle: @escaping () -> Void, |
|
| 28 |
+ onOpenSettings: @escaping () -> Void, |
|
| 29 |
+ @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination, |
|
| 30 |
+ @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination |
|
| 31 |
+ ) {
|
|
| 32 |
+ self.activeReason = activeReason |
|
| 33 |
+ self.isExpanded = isExpanded |
|
| 34 |
+ self.bluetoothStatusTint = bluetoothStatusTint |
|
| 35 |
+ self.bluetoothStatusText = bluetoothStatusText |
|
| 36 |
+ self.cloudSyncHelpTitle = cloudSyncHelpTitle |
|
| 37 |
+ self.cloudSyncHelpMessage = cloudSyncHelpMessage |
|
| 38 |
+ self.onToggle = onToggle |
|
| 39 |
+ self.onOpenSettings = onOpenSettings |
|
| 40 |
+ self.bluetoothHelpDestination = bluetoothHelpDestination() |
|
| 41 |
+ self.deviceHelpDestination = deviceHelpDestination() |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ var body: some View {
|
|
| 45 |
+ Section(header: Text("Help & Troubleshooting").font(.headline)) {
|
|
| 46 |
+ Button(action: onToggle) {
|
|
| 47 |
+ HStack(spacing: 14) {
|
|
| 48 |
+ Image(systemName: sectionSymbol) |
|
| 49 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 50 |
+ .foregroundColor(sectionTint) |
|
| 51 |
+ .frame(width: 42, height: 42) |
|
| 52 |
+ .background(Circle().fill(sectionTint.opacity(0.18))) |
|
| 53 |
+ |
|
| 54 |
+ Text("Help")
|
|
| 55 |
+ .font(.headline) |
|
| 56 |
+ |
|
| 57 |
+ Spacer() |
|
| 58 |
+ |
|
| 59 |
+ if let activeReason {
|
|
| 60 |
+ Text(activeReason.badgeTitle) |
|
| 61 |
+ .font(.caption2.weight(.bold)) |
|
| 62 |
+ .foregroundColor(activeReason.tint) |
|
| 63 |
+ .padding(.horizontal, 10) |
|
| 64 |
+ .padding(.vertical, 6) |
|
| 65 |
+ .background( |
|
| 66 |
+ Capsule(style: .continuous) |
|
| 67 |
+ .fill(activeReason.tint.opacity(0.12)) |
|
| 68 |
+ ) |
|
| 69 |
+ .overlay( |
|
| 70 |
+ Capsule(style: .continuous) |
|
| 71 |
+ .stroke(activeReason.tint.opacity(0.22), lineWidth: 1) |
|
| 72 |
+ ) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 76 |
+ .font(.footnote.weight(.bold)) |
|
| 77 |
+ .foregroundColor(.secondary) |
|
| 78 |
+ } |
|
| 79 |
+ .padding(14) |
|
| 80 |
+ .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 81 |
+ } |
|
| 82 |
+ .buttonStyle(.plain) |
|
| 83 |
+ |
|
| 84 |
+ if isExpanded {
|
|
| 85 |
+ if let activeReason {
|
|
| 86 |
+ SidebarHelpNoticeCardView( |
|
| 87 |
+ reason: activeReason, |
|
| 88 |
+ cloudSyncHelpTitle: cloudSyncHelpTitle, |
|
| 89 |
+ cloudSyncHelpMessage: cloudSyncHelpMessage |
|
| 90 |
+ ) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ SidebarBluetoothStatusCardView( |
|
| 94 |
+ tint: bluetoothStatusTint, |
|
| 95 |
+ statusText: bluetoothStatusText |
|
| 96 |
+ ) |
|
| 97 |
+ |
|
| 98 |
+ if activeReason == .cloudSyncUnavailable {
|
|
| 99 |
+ Button(action: onOpenSettings) {
|
|
| 100 |
+ SidebarLinkCardView( |
|
| 101 |
+ title: "Open Settings", |
|
| 102 |
+ subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.", |
|
| 103 |
+ symbol: "gearshape.fill", |
|
| 104 |
+ tint: .indigo |
|
| 105 |
+ ) |
|
| 106 |
+ } |
|
| 107 |
+ .buttonStyle(.plain) |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ NavigationLink(destination: bluetoothHelpDestination) {
|
|
| 111 |
+ SidebarLinkCardView( |
|
| 112 |
+ title: "Bluetooth", |
|
| 113 |
+ subtitle: "Permissions, adapter state, and connection tips.", |
|
| 114 |
+ symbol: "bolt.horizontal.circle.fill", |
|
| 115 |
+ tint: bluetoothStatusTint |
|
| 116 |
+ ) |
|
| 117 |
+ } |
|
| 118 |
+ .buttonStyle(.plain) |
|
| 119 |
+ |
|
| 120 |
+ NavigationLink(destination: deviceHelpDestination) {
|
|
| 121 |
+ SidebarLinkCardView( |
|
| 122 |
+ title: "Device", |
|
| 123 |
+ subtitle: "Quick checks when a meter is not responding as expected.", |
|
| 124 |
+ symbol: "questionmark.circle.fill", |
|
| 125 |
+ tint: .orange |
|
| 126 |
+ ) |
|
| 127 |
+ } |
|
| 128 |
+ .buttonStyle(.plain) |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+ private var sectionTint: Color {
|
|
| 135 |
+ activeReason?.tint ?? .secondary |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ private var sectionSymbol: String {
|
|
| 139 |
+ activeReason?.symbol ?? "questionmark.circle.fill" |
|
| 140 |
+ } |
|
| 141 |
+} |
|
@@ -0,0 +1,30 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarBluetoothStatusCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarBluetoothStatusCardView: View {
|
|
| 9 |
+ let tint: Color |
|
| 10 |
+ let statusText: String |
|
| 11 |
+ |
|
| 12 |
+ var body: some View {
|
|
| 13 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 14 |
+ HStack {
|
|
| 15 |
+ Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
|
|
| 16 |
+ .font(.footnote.weight(.semibold)) |
|
| 17 |
+ .foregroundColor(tint) |
|
| 18 |
+ Spacer() |
|
| 19 |
+ Text(statusText) |
|
| 20 |
+ .font(.caption.weight(.semibold)) |
|
| 21 |
+ .foregroundColor(.secondary) |
|
| 22 |
+ } |
|
| 23 |
+ Text("Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.")
|
|
| 24 |
+ .font(.caption2) |
|
| 25 |
+ .foregroundColor(.secondary) |
|
| 26 |
+ } |
|
| 27 |
+ .padding(14) |
|
| 28 |
+ .meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18) |
|
| 29 |
+ } |
|
| 30 |
+} |
|
@@ -0,0 +1,47 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpNoticeCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarHelpNoticeCardView: View {
|
|
| 9 |
+ let reason: SidebarHelpReason |
|
| 10 |
+ let cloudSyncHelpTitle: String |
|
| 11 |
+ let cloudSyncHelpMessage: String |
|
| 12 |
+ |
|
| 13 |
+ var body: some View {
|
|
| 14 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 15 |
+ Text(helpNoticeTitle) |
|
| 16 |
+ .font(.subheadline.weight(.semibold)) |
|
| 17 |
+ Text(helpNoticeDetail) |
|
| 18 |
+ .font(.caption) |
|
| 19 |
+ .foregroundColor(.secondary) |
|
| 20 |
+ } |
|
| 21 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 22 |
+ .padding(14) |
|
| 23 |
+ .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ private var helpNoticeTitle: String {
|
|
| 27 |
+ switch reason {
|
|
| 28 |
+ case .bluetoothPermission: |
|
| 29 |
+ return "Bluetooth access needs attention" |
|
| 30 |
+ case .cloudSyncUnavailable: |
|
| 31 |
+ return cloudSyncHelpTitle |
|
| 32 |
+ case .noDevicesDetected: |
|
| 33 |
+ return "No supported meters found yet" |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ private var helpNoticeDetail: String {
|
|
| 38 |
+ switch reason {
|
|
| 39 |
+ case .bluetoothPermission: |
|
| 40 |
+ return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked." |
|
| 41 |
+ case .cloudSyncUnavailable: |
|
| 42 |
+ return cloudSyncHelpMessage |
|
| 43 |
+ case .noDevicesDetected: |
|
| 44 |
+ return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone." |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+} |
|
@@ -0,0 +1,45 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpReason.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+enum SidebarHelpReason: String {
|
|
| 9 |
+ case bluetoothPermission |
|
| 10 |
+ case cloudSyncUnavailable |
|
| 11 |
+ case noDevicesDetected |
|
| 12 |
+ |
|
| 13 |
+ var tint: Color {
|
|
| 14 |
+ switch self {
|
|
| 15 |
+ case .bluetoothPermission: |
|
| 16 |
+ return .orange |
|
| 17 |
+ case .cloudSyncUnavailable: |
|
| 18 |
+ return .indigo |
|
| 19 |
+ case .noDevicesDetected: |
|
| 20 |
+ return .yellow |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ var symbol: String {
|
|
| 25 |
+ switch self {
|
|
| 26 |
+ case .bluetoothPermission: |
|
| 27 |
+ return "bolt.horizontal.circle.fill" |
|
| 28 |
+ case .cloudSyncUnavailable: |
|
| 29 |
+ return "icloud.slash.fill" |
|
| 30 |
+ case .noDevicesDetected: |
|
| 31 |
+ return "magnifyingglass.circle.fill" |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ var badgeTitle: String {
|
|
| 36 |
+ switch self {
|
|
| 37 |
+ case .bluetoothPermission: |
|
| 38 |
+ return "Required" |
|
| 39 |
+ case .cloudSyncUnavailable: |
|
| 40 |
+ return "Sync Off" |
|
| 41 |
+ case .noDevicesDetected: |
|
| 42 |
+ return "Suggested" |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+} |
|
@@ -0,0 +1,22 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarDebugSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarDebugSectionView: View {
|
|
| 9 |
+ var body: some View {
|
|
| 10 |
+ Section(header: Text("Debug").font(.headline)) {
|
|
| 11 |
+ NavigationLink(destination: MeterMappingDebugView()) {
|
|
| 12 |
+ SidebarLinkCardView( |
|
| 13 |
+ title: "Meter Sync Debug", |
|
| 14 |
+ subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.", |
|
| 15 |
+ symbol: "list.bullet.rectangle", |
|
| 16 |
+ tint: .purple |
|
| 17 |
+ ) |
|
| 18 |
+ } |
|
| 19 |
+ .buttonStyle(.plain) |
|
| 20 |
+ } |
|
| 21 |
+ } |
|
| 22 |
+} |
|
@@ -0,0 +1,141 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
|
|
| 9 |
+ let activeReason: SidebarHelpReason? |
|
| 10 |
+ let isExpanded: Bool |
|
| 11 |
+ let bluetoothStatusTint: Color |
|
| 12 |
+ let bluetoothStatusText: String |
|
| 13 |
+ let cloudSyncHelpTitle: String |
|
| 14 |
+ let cloudSyncHelpMessage: String |
|
| 15 |
+ let onToggle: () -> Void |
|
| 16 |
+ let onOpenSettings: () -> Void |
|
| 17 |
+ let bluetoothHelpDestination: BluetoothHelpDestination |
|
| 18 |
+ let deviceHelpDestination: DeviceHelpDestination |
|
| 19 |
+ |
|
| 20 |
+ init( |
|
| 21 |
+ activeReason: SidebarHelpReason?, |
|
| 22 |
+ isExpanded: Bool, |
|
| 23 |
+ bluetoothStatusTint: Color, |
|
| 24 |
+ bluetoothStatusText: String, |
|
| 25 |
+ cloudSyncHelpTitle: String, |
|
| 26 |
+ cloudSyncHelpMessage: String, |
|
| 27 |
+ onToggle: @escaping () -> Void, |
|
| 28 |
+ onOpenSettings: @escaping () -> Void, |
|
| 29 |
+ @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination, |
|
| 30 |
+ @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination |
|
| 31 |
+ ) {
|
|
| 32 |
+ self.activeReason = activeReason |
|
| 33 |
+ self.isExpanded = isExpanded |
|
| 34 |
+ self.bluetoothStatusTint = bluetoothStatusTint |
|
| 35 |
+ self.bluetoothStatusText = bluetoothStatusText |
|
| 36 |
+ self.cloudSyncHelpTitle = cloudSyncHelpTitle |
|
| 37 |
+ self.cloudSyncHelpMessage = cloudSyncHelpMessage |
|
| 38 |
+ self.onToggle = onToggle |
|
| 39 |
+ self.onOpenSettings = onOpenSettings |
|
| 40 |
+ self.bluetoothHelpDestination = bluetoothHelpDestination() |
|
| 41 |
+ self.deviceHelpDestination = deviceHelpDestination() |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ var body: some View {
|
|
| 45 |
+ Section(header: Text("Help & Troubleshooting").font(.headline)) {
|
|
| 46 |
+ Button(action: onToggle) {
|
|
| 47 |
+ HStack(spacing: 14) {
|
|
| 48 |
+ Image(systemName: sectionSymbol) |
|
| 49 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 50 |
+ .foregroundColor(sectionTint) |
|
| 51 |
+ .frame(width: 42, height: 42) |
|
| 52 |
+ .background(Circle().fill(sectionTint.opacity(0.18))) |
|
| 53 |
+ |
|
| 54 |
+ Text("Help")
|
|
| 55 |
+ .font(.headline) |
|
| 56 |
+ |
|
| 57 |
+ Spacer() |
|
| 58 |
+ |
|
| 59 |
+ if let activeReason {
|
|
| 60 |
+ Text(activeReason.badgeTitle) |
|
| 61 |
+ .font(.caption2.weight(.bold)) |
|
| 62 |
+ .foregroundColor(activeReason.tint) |
|
| 63 |
+ .padding(.horizontal, 10) |
|
| 64 |
+ .padding(.vertical, 6) |
|
| 65 |
+ .background( |
|
| 66 |
+ Capsule(style: .continuous) |
|
| 67 |
+ .fill(activeReason.tint.opacity(0.12)) |
|
| 68 |
+ ) |
|
| 69 |
+ .overlay( |
|
| 70 |
+ Capsule(style: .continuous) |
|
| 71 |
+ .stroke(activeReason.tint.opacity(0.22), lineWidth: 1) |
|
| 72 |
+ ) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 76 |
+ .font(.footnote.weight(.bold)) |
|
| 77 |
+ .foregroundColor(.secondary) |
|
| 78 |
+ } |
|
| 79 |
+ .padding(14) |
|
| 80 |
+ .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 81 |
+ } |
|
| 82 |
+ .buttonStyle(.plain) |
|
| 83 |
+ |
|
| 84 |
+ if isExpanded {
|
|
| 85 |
+ if let activeReason {
|
|
| 86 |
+ SidebarHelpNoticeCardView( |
|
| 87 |
+ reason: activeReason, |
|
| 88 |
+ cloudSyncHelpTitle: cloudSyncHelpTitle, |
|
| 89 |
+ cloudSyncHelpMessage: cloudSyncHelpMessage |
|
| 90 |
+ ) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ SidebarBluetoothStatusCardView( |
|
| 94 |
+ tint: bluetoothStatusTint, |
|
| 95 |
+ statusText: bluetoothStatusText |
|
| 96 |
+ ) |
|
| 97 |
+ |
|
| 98 |
+ if activeReason == .cloudSyncUnavailable {
|
|
| 99 |
+ Button(action: onOpenSettings) {
|
|
| 100 |
+ SidebarLinkCardView( |
|
| 101 |
+ title: "Open Settings", |
|
| 102 |
+ subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.", |
|
| 103 |
+ symbol: "gearshape.fill", |
|
| 104 |
+ tint: .indigo |
|
| 105 |
+ ) |
|
| 106 |
+ } |
|
| 107 |
+ .buttonStyle(.plain) |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ NavigationLink(destination: bluetoothHelpDestination) {
|
|
| 111 |
+ SidebarLinkCardView( |
|
| 112 |
+ title: "Bluetooth", |
|
| 113 |
+ subtitle: "Permissions, adapter state, and connection tips.", |
|
| 114 |
+ symbol: "bolt.horizontal.circle.fill", |
|
| 115 |
+ tint: bluetoothStatusTint |
|
| 116 |
+ ) |
|
| 117 |
+ } |
|
| 118 |
+ .buttonStyle(.plain) |
|
| 119 |
+ |
|
| 120 |
+ NavigationLink(destination: deviceHelpDestination) {
|
|
| 121 |
+ SidebarLinkCardView( |
|
| 122 |
+ title: "Device", |
|
| 123 |
+ subtitle: "Quick checks when a meter is not responding as expected.", |
|
| 124 |
+ symbol: "questionmark.circle.fill", |
|
| 125 |
+ tint: .orange |
|
| 126 |
+ ) |
|
| 127 |
+ } |
|
| 128 |
+ .buttonStyle(.plain) |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+ private var sectionTint: Color {
|
|
| 135 |
+ activeReason?.tint ?? .secondary |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ private var sectionSymbol: String {
|
|
| 139 |
+ activeReason?.symbol ?? "questionmark.circle.fill" |
|
| 140 |
+ } |
|
| 141 |
+} |
|
@@ -0,0 +1,81 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarUSBMetersSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+import CoreBluetooth |
|
| 8 |
+ |
|
| 9 |
+struct SidebarUSBMetersSectionView: View {
|
|
| 10 |
+ let meters: [AppData.MeterSummary] |
|
| 11 |
+ let managerState: CBManagerState |
|
| 12 |
+ let hasLiveMeters: Bool |
|
| 13 |
+ let scanStartedAt: Date? |
|
| 14 |
+ let now: Date |
|
| 15 |
+ let noDevicesHelpDelay: TimeInterval |
|
| 16 |
+ |
|
| 17 |
+ var body: some View {
|
|
| 18 |
+ Section(header: usbSectionHeader) {
|
|
| 19 |
+ if meters.isEmpty {
|
|
| 20 |
+ Text(devicesEmptyStateText) |
|
| 21 |
+ .font(.footnote) |
|
| 22 |
+ .foregroundColor(.secondary) |
|
| 23 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 24 |
+ .padding(18) |
|
| 25 |
+ .meterCard( |
|
| 26 |
+ tint: isWaitingForFirstDiscovery ? .blue : .secondary, |
|
| 27 |
+ fillOpacity: 0.14, |
|
| 28 |
+ strokeOpacity: 0.20 |
|
| 29 |
+ ) |
|
| 30 |
+ } else {
|
|
| 31 |
+ ForEach(meters) { meterSummary in
|
|
| 32 |
+ if let meter = meterSummary.meter {
|
|
| 33 |
+ NavigationLink(destination: MeterView().environmentObject(meter)) {
|
|
| 34 |
+ MeterRowView() |
|
| 35 |
+ .environmentObject(meter) |
|
| 36 |
+ } |
|
| 37 |
+ .buttonStyle(.plain) |
|
| 38 |
+ } else {
|
|
| 39 |
+ NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
|
|
| 40 |
+ MeterCardView(meterSummary: meterSummary) |
|
| 41 |
+ } |
|
| 42 |
+ .buttonStyle(.plain) |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ private var isWaitingForFirstDiscovery: Bool {
|
|
| 50 |
+ guard managerState == .poweredOn else {
|
|
| 51 |
+ return false |
|
| 52 |
+ } |
|
| 53 |
+ guard hasLiveMeters == false else {
|
|
| 54 |
+ return false |
|
| 55 |
+ } |
|
| 56 |
+ guard let scanStartedAt else {
|
|
| 57 |
+ return false |
|
| 58 |
+ } |
|
| 59 |
+ return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ private var devicesEmptyStateText: String {
|
|
| 63 |
+ if isWaitingForFirstDiscovery {
|
|
| 64 |
+ return "Scanning for nearby supported meters..." |
|
| 65 |
+ } |
|
| 66 |
+ return "No meters yet. Nearby supported meters will appear here and remain available after they disappear." |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ private var usbSectionHeader: some View {
|
|
| 70 |
+ HStack {
|
|
| 71 |
+ Text("USB Meters")
|
|
| 72 |
+ .font(.headline) |
|
| 73 |
+ Spacer() |
|
| 74 |
+ Text("\(meters.count)")
|
|
| 75 |
+ .font(.caption.weight(.bold)) |
|
| 76 |
+ .padding(.horizontal, 10) |
|
| 77 |
+ .padding(.vertical, 6) |
|
| 78 |
+ .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 79 |
+ } |
|
| 80 |
+ } |
|
| 81 |
+} |
|
@@ -0,0 +1,53 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarListView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarListView<USBMetersSection: View, HelpSection: View, DebugSection: View>: View {
|
|
| 9 |
+ let backgroundTint: Color |
|
| 10 |
+ let usbMetersSection: USBMetersSection |
|
| 11 |
+ let helpSection: HelpSection |
|
| 12 |
+ let debugSection: DebugSection |
|
| 13 |
+ |
|
| 14 |
+ init( |
|
| 15 |
+ backgroundTint: Color, |
|
| 16 |
+ @ViewBuilder usbMetersSection: () -> USBMetersSection, |
|
| 17 |
+ @ViewBuilder helpSection: () -> HelpSection, |
|
| 18 |
+ @ViewBuilder debugSection: () -> DebugSection |
|
| 19 |
+ ) {
|
|
| 20 |
+ self.backgroundTint = backgroundTint |
|
| 21 |
+ self.usbMetersSection = usbMetersSection() |
|
| 22 |
+ self.helpSection = helpSection() |
|
| 23 |
+ self.debugSection = debugSection() |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ var body: some View {
|
|
| 27 |
+ if #available(iOS 16.0, *) {
|
|
| 28 |
+ listBody.scrollContentBackground(.hidden) |
|
| 29 |
+ } else {
|
|
| 30 |
+ listBody |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ private var listBody: some View {
|
|
| 35 |
+ List {
|
|
| 36 |
+ usbMetersSection |
|
| 37 |
+ helpSection |
|
| 38 |
+ debugSection |
|
| 39 |
+ } |
|
| 40 |
+ .listStyle(SidebarListStyle()) |
|
| 41 |
+ .background( |
|
| 42 |
+ LinearGradient( |
|
| 43 |
+ colors: [ |
|
| 44 |
+ backgroundTint.opacity(0.18), |
|
| 45 |
+ Color.clear |
|
| 46 |
+ ], |
|
| 47 |
+ startPoint: .topLeading, |
|
| 48 |
+ endPoint: .bottomTrailing |
|
| 49 |
+ ) |
|
| 50 |
+ .ignoresSafeArea() |
|
| 51 |
+ ) |
|
| 52 |
+ } |
|
| 53 |
+} |
|
@@ -0,0 +1,129 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+import Combine |
|
| 8 |
+ |
|
| 9 |
+struct SidebarView: View {
|
|
| 10 |
+ @EnvironmentObject private var appData: AppData |
|
| 11 |
+ @State private var isHelpExpanded = false |
|
| 12 |
+ @State private var dismissedAutoHelpReason: SidebarHelpReason? |
|
| 13 |
+ @State private var now = Date() |
|
| 14 |
+ private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() |
|
| 15 |
+ private let noDevicesHelpDelay: TimeInterval = 12 |
|
| 16 |
+ |
|
| 17 |
+ var body: some View {
|
|
| 18 |
+ SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
|
|
| 19 |
+ usbMetersSection |
|
| 20 |
+ } helpSection: {
|
|
| 21 |
+ helpSection |
|
| 22 |
+ } debugSection: {
|
|
| 23 |
+ debugSection |
|
| 24 |
+ } |
|
| 25 |
+ .onAppear {
|
|
| 26 |
+ appData.bluetoothManager.start() |
|
| 27 |
+ now = Date() |
|
| 28 |
+ } |
|
| 29 |
+ .onReceive(helpRefreshTimer) { currentDate in
|
|
| 30 |
+ now = currentDate |
|
| 31 |
+ } |
|
| 32 |
+ .onChange(of: activeHelpAutoReason) { newReason in
|
|
| 33 |
+ if newReason == nil {
|
|
| 34 |
+ dismissedAutoHelpReason = nil |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ private var usbMetersSection: some View {
|
|
| 40 |
+ SidebarUSBMetersSectionView( |
|
| 41 |
+ meters: appData.meterSummaries, |
|
| 42 |
+ managerState: appData.bluetoothManager.managerState, |
|
| 43 |
+ hasLiveMeters: appData.meters.isEmpty == false, |
|
| 44 |
+ scanStartedAt: appData.bluetoothManager.scanStartedAt, |
|
| 45 |
+ now: now, |
|
| 46 |
+ noDevicesHelpDelay: noDevicesHelpDelay |
|
| 47 |
+ ) |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private var helpSection: some View {
|
|
| 51 |
+ SidebarHelpSectionView( |
|
| 52 |
+ activeReason: activeHelpAutoReason, |
|
| 53 |
+ isExpanded: helpIsExpanded, |
|
| 54 |
+ bluetoothStatusTint: appData.bluetoothManager.managerState.color, |
|
| 55 |
+ bluetoothStatusText: bluetoothStatusText, |
|
| 56 |
+ cloudSyncHelpTitle: appData.cloudAvailability.helpTitle, |
|
| 57 |
+ cloudSyncHelpMessage: appData.cloudAvailability.helpMessage, |
|
| 58 |
+ onToggle: toggleHelpSection, |
|
| 59 |
+ onOpenSettings: openSettings |
|
| 60 |
+ ) {
|
|
| 61 |
+ appData.bluetoothManager.managerState.helpView |
|
| 62 |
+ } deviceHelpDestination: {
|
|
| 63 |
+ DeviceHelpView() |
|
| 64 |
+ } |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ private var debugSection: some View {
|
|
| 68 |
+ SidebarDebugSectionView() |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ private var bluetoothStatusText: String {
|
|
| 72 |
+ switch appData.bluetoothManager.managerState {
|
|
| 73 |
+ case .poweredOff: |
|
| 74 |
+ return "Off" |
|
| 75 |
+ case .poweredOn: |
|
| 76 |
+ return "On" |
|
| 77 |
+ case .resetting: |
|
| 78 |
+ return "Resetting" |
|
| 79 |
+ case .unauthorized: |
|
| 80 |
+ return "Unauthorized" |
|
| 81 |
+ case .unknown: |
|
| 82 |
+ return "Unknown" |
|
| 83 |
+ case .unsupported: |
|
| 84 |
+ return "Unsupported" |
|
| 85 |
+ @unknown default: |
|
| 86 |
+ return "Other" |
|
| 87 |
+ } |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ private var helpIsExpanded: Bool {
|
|
| 91 |
+ isHelpExpanded || shouldAutoExpandHelp |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ private var shouldAutoExpandHelp: Bool {
|
|
| 95 |
+ guard let activeHelpAutoReason else {
|
|
| 96 |
+ return false |
|
| 97 |
+ } |
|
| 98 |
+ return dismissedAutoHelpReason != activeHelpAutoReason |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ private var activeHelpAutoReason: SidebarHelpReason? {
|
|
| 102 |
+ SidebarAutoHelpResolver.activeReason( |
|
| 103 |
+ managerState: appData.bluetoothManager.managerState, |
|
| 104 |
+ cloudAvailability: appData.cloudAvailability, |
|
| 105 |
+ hasLiveMeters: appData.meters.isEmpty == false, |
|
| 106 |
+ scanStartedAt: appData.bluetoothManager.scanStartedAt, |
|
| 107 |
+ now: now, |
|
| 108 |
+ noDevicesHelpDelay: noDevicesHelpDelay |
|
| 109 |
+ ) |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ private func toggleHelpSection() {
|
|
| 113 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 114 |
+ if shouldAutoExpandHelp {
|
|
| 115 |
+ dismissedAutoHelpReason = activeHelpAutoReason |
|
| 116 |
+ isHelpExpanded = false |
|
| 117 |
+ } else {
|
|
| 118 |
+ isHelpExpanded.toggle() |
|
| 119 |
+ } |
|
| 120 |
+ } |
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 123 |
+ private func openSettings() {
|
|
| 124 |
+ guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
|
|
| 125 |
+ return |
|
| 126 |
+ } |
|
| 127 |
+ UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) |
|
| 128 |
+ } |
|
| 129 |
+} |
|