1 contributor
import SwiftUI
struct MeterDetailView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
@State private var editorVisibility = false
@State private var deleteConfirmationVisibility = false
let meterSummary: AppData.MeterSummary
var body: some View {
ScrollView {
VStack(spacing: 18) {
headerCard
statusCard
identifiersCard
chargedDevicesCard
chargersCard
}
.padding()
}
.background(
LinearGradient(
colors: [meterSummary.tint.opacity(0.18), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle(meterSummary.displayName)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Edit") {
editorVisibility = true
}
Button(role: .destructive) {
deleteConfirmationVisibility = true
} label: {
Image(systemName: "trash")
}
}
}
.sheet(isPresented: $editorVisibility) {
MeterEditorSheetView(existingMeterSummary: meterSummary)
.environmentObject(appData)
}
.alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
Button("Delete", role: .destructive) {
if appData.deleteMeter(macAddress: meterSummary.macAddress) {
dismiss()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the stored meter entry and its saved metadata from the sidebar until the meter is discovered again.")
}
}
private var headerCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text(meterSummary.displayName)
.font(.title2.weight(.semibold))
Text(meterSummary.modelSummary)
.font(.subheadline)
.foregroundColor(.secondary)
if let advertisedName = meterSummary.advertisedName {
Text("Advertised as " + advertisedName)
.font(.caption2)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: meterSummary.tint, fillOpacity: 0.22, strokeOpacity: 0.28, cornerRadius: 20)
}
private var statusCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Status")
.font(.headline)
ContextInfoButton(
title: "Status",
message: "The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics."
)
}
HStack(spacing: 8) {
Circle()
.fill(meterSummary.tint)
.frame(width: 10, height: 10)
Text("Offline")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: meterSummary.tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
}
private var identifiersCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Identifiers")
.font(.headline)
infoRow(label: "MAC Address", value: meterSummary.macAddress)
if let advertisedName = meterSummary.advertisedName {
infoRow(label: "Advertised as", value: advertisedName)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
}
private var chargedDevicesCard: some View {
let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress)
return VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Devices")
.font(.headline)
ContextInfoButton(
title: "Devices",
message: "Link devices to this meter from Charge Record to keep capacity learning and charge curves tied to the right hardware."
)
}
if chargedDevices.isEmpty {
Text("No devices linked yet.")
.font(.caption)
.foregroundColor(.secondary)
} else {
ForEach(chargedDevices.prefix(3)) { chargedDevice in
NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
HStack(spacing: 12) {
ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
VStack(alignment: .leading, spacing: 4) {
ChargedDeviceIdentityLabelView(
chargedDevice: chargedDevice,
iconPointSize: 15
)
.font(.subheadline.weight(.semibold))
Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
}
private var chargersCard: some View {
let chargers = appData.chargers(for: meterSummary.macAddress)
return VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Chargers")
.font(.headline)
ContextInfoButton(
title: "Chargers",
message: "Link chargers to this meter for wireless sessions so the app can keep charger-specific learning and efficiency data separate."
)
}
if chargers.isEmpty {
Text("No chargers linked yet.")
.font(.caption)
.foregroundColor(.secondary)
} else {
ForEach(chargers.prefix(3)) { charger in
NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: charger.id)) {
HStack(spacing: 12) {
ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
VStack(alignment: .leading, spacing: 4) {
ChargedDeviceIdentityLabelView(
chargedDevice: charger,
iconPointSize: 15
)
.font(.subheadline.weight(.semibold))
Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
}
private func infoRow(label: String, value: String) -> some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundColor(.secondary)
.font(.caption)
}
}
}
struct MeterDetailView_Previews: PreviewProvider {
static var previews: some View {
MeterDetailView(
meterSummary: AppData.MeterSummary(
macAddress: "AA:BB:CC:DD:EE:FF",
displayName: "Desk Meter",
modelSummary: "UM25C",
advertisedName: "UM25C-123",
lastSeen: Date(),
lastConnected: Date().addingTimeInterval(-3600),
meter: nil
)
)
.environmentObject(appData)
}
}
struct MeterEditorSheetView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
let existingMeterSummary: AppData.MeterSummary?
@State private var customName: String
@State private var macAddress: String
@State private var advertisedName: String
@State private var selectedModel: Model
init(existingMeterSummary: AppData.MeterSummary? = nil) {
self.existingMeterSummary = existingMeterSummary
_customName = State(initialValue: existingMeterSummary?.displayName ?? "")
_macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
_advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
_selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
}
var body: some View {
NavigationView {
Form {
Section(
header: ContextInfoHeader(
title: "Identity",
message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline."
)
) {
TextField("Display name", text: $customName)
TextField("MAC Address", text: $macAddress)
.textInputAutocapitalization(.characters)
.disableAutocorrection(true)
.disabled(existingMeterSummary != nil)
Picker("Model", selection: $selectedModel) {
ForEach(Model.allCases, id: \.self) { model in
Text(model.canonicalName)
.tag(model)
}
}
TextField("Advertised name", text: $advertisedName)
}
}
.navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(existingMeterSummary == nil ? "Save" : "Update") {
let normalizedMAC = AppData.normalizedMACAddress(macAddress)
let didSave = appData.createKnownMeter(
macAddress: normalizedMAC,
customName: customName,
modelName: selectedModel.canonicalName,
advertisedName: advertisedName
)
if didSave {
dismiss()
}
}
.disabled(isSaveDisabled)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private var isSaveDisabled: Bool {
AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
}
private static func model(for summary: String?) -> Model {
if summary?.contains("UM34C") == true {
return .UM34C
}
if summary?.contains("TC66C") == true {
return .TC66C
}
return .UM25C
}
}