1 contributor
import SwiftUI
/// Offline meter view - shows meter information when not connected.
/// Uses same tab-based layout as MeterView but filtered to available operations.
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
private let meterColor = Color.orange
var body: some View {
GeometryReader { proxy in
let landscape = proxy.size.width > proxy.size.height
VStack(spacing: 0) {
// Compact header matching online meters
HStack(spacing: 12) {
Text(meterSummary.displayName.isEmpty ? "Meter" : meterSummary.displayName)
.font(.headline)
.lineLimit(1)
Spacer()
Button("Edit") {
editorVisibility = true
}
.font(.body.weight(.semibold))
Button(role: .destructive) {
deleteConfirmationVisibility = true
} label: {
Image(systemName: "trash")
}
.font(.body.weight(.semibold))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .top)
)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.secondary.opacity(0.12))
.frame(height: 1)
}
// Content
ScrollView {
VStack(spacing: 18) {
headerCard
statusCard
identifiersCard
}
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
.background(
LinearGradient(
colors: [meterColor.opacity(0.18), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationBarHidden(true)
.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: meterColor, 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: "This meter is offline. Bring it within Bluetooth range to connect and view live data."
)
}
HStack(spacing: 8) {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
Text("Offline")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.meterCard(tint: meterColor, 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 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
)
)
}
}
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
}
}