USB-Meter / USB Meter / Views / MeterDetailView.swift
Newer Older
258 lines | 9.629kb
Bogdan Timofte authored 2 months ago
1
import SwiftUI
2

            
Bogdan Timofte authored a month ago
3
/// Offline meter view - shows meter information when not connected.
4
/// Uses same tab-based layout as MeterView but filtered to available operations.
Bogdan Timofte authored 2 months ago
5
struct MeterDetailView: View {
Bogdan Timofte authored a month ago
6
    @EnvironmentObject private var appData: AppData
7
    @Environment(\.dismiss) private var dismiss
8
    @State private var editorVisibility = false
9
    @State private var deleteConfirmationVisibility = false
10

            
Bogdan Timofte authored 2 months ago
11
    let meterSummary: AppData.MeterSummary
Bogdan Timofte authored 2 months ago
12

            
Bogdan Timofte authored a month ago
13
    private let meterColor = Color.orange
14

            
Bogdan Timofte authored 2 months ago
15
    var body: some View {
Bogdan Timofte authored a month ago
16
        GeometryReader { proxy in
17
            let landscape = proxy.size.width > proxy.size.height
18

            
19
            VStack(spacing: 0) {
20
                // Compact header matching online meters
21
                HStack(spacing: 12) {
22
                    Text(meterSummary.displayName.isEmpty ? "Meter" : meterSummary.displayName)
23
                        .font(.headline)
24
                        .lineLimit(1)
25

            
26
                    Spacer()
27

            
28
                    Button("Edit") {
29
                        editorVisibility = true
30
                    }
31
                    .font(.body.weight(.semibold))
32

            
33
                    Button(role: .destructive) {
34
                        deleteConfirmationVisibility = true
35
                    } label: {
36
                        Image(systemName: "trash")
37
                    }
38
                    .font(.body.weight(.semibold))
39
                }
40
                .padding(.horizontal, 16)
41
                .padding(.vertical, 10)
42
                .background(
43
                    Rectangle()
44
                        .fill(.ultraThinMaterial)
45
                        .ignoresSafeArea(edges: .top)
46
                )
47
                .overlay(alignment: .bottom) {
48
                    Rectangle()
49
                        .fill(Color.secondary.opacity(0.12))
50
                        .frame(height: 1)
51
                }
52

            
53
                // Content
54
                ScrollView {
55
                    VStack(spacing: 18) {
56
                        headerCard
57
                        statusCard
58
                        identifiersCard
59
                    }
60
                    .padding()
61
                }
62
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 months ago
63
            }
64
        }
65
        .background(
66
            LinearGradient(
Bogdan Timofte authored a month ago
67
                colors: [meterColor.opacity(0.18), Color.clear],
Bogdan Timofte authored 2 months ago
68
                startPoint: .topLeading,
69
                endPoint: .bottomTrailing
70
            )
71
            .ignoresSafeArea()
72
        )
Bogdan Timofte authored a month ago
73
        .navigationBarHidden(true)
Bogdan Timofte authored a month ago
74
        .sheet(isPresented: $editorVisibility) {
75
            MeterEditorSheetView(existingMeterSummary: meterSummary)
76
                .environmentObject(appData)
77
        }
78
        .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
79
            Button("Delete", role: .destructive) {
80
                if appData.deleteMeter(macAddress: meterSummary.macAddress) {
81
                    dismiss()
82
                }
83
            }
84
            Button("Cancel", role: .cancel) {}
85
        } message: {
86
            Text("This removes the stored meter entry and its saved metadata from the sidebar until the meter is discovered again.")
87
        }
Bogdan Timofte authored 2 months ago
88
    }
89

            
90
    private var headerCard: some View {
91
        VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored 2 months ago
92
            Text(meterSummary.displayName)
Bogdan Timofte authored 2 months ago
93
                .font(.title2.weight(.semibold))
Bogdan Timofte authored 2 months ago
94
            Text(meterSummary.modelSummary)
Bogdan Timofte authored 2 months ago
95
                .font(.subheadline)
96
                .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
97
            if let advertisedName = meterSummary.advertisedName {
Bogdan Timofte authored 2 months ago
98
                Text("Advertised as " + advertisedName)
99
                    .font(.caption2)
100
                    .foregroundColor(.secondary)
101
            }
102
        }
103
        .frame(maxWidth: .infinity, alignment: .leading)
104
        .padding(18)
Bogdan Timofte authored a month ago
105
        .meterCard(tint: meterColor, fillOpacity: 0.22, strokeOpacity: 0.28, cornerRadius: 20)
Bogdan Timofte authored 2 months ago
106
    }
107

            
108
    private var statusCard: some View {
109
        VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
110
            HStack(spacing: 8) {
111
                Text("Status")
112
                    .font(.headline)
113
                ContextInfoButton(
114
                    title: "Status",
Bogdan Timofte authored a month ago
115
                    message: "This meter is offline. Bring it within Bluetooth range to connect and view live data."
Bogdan Timofte authored a month ago
116
                )
117
            }
Bogdan Timofte authored 2 months ago
118
            HStack(spacing: 8) {
119
                Circle()
Bogdan Timofte authored a month ago
120
                    .fill(Color.red)
Bogdan Timofte authored 2 months ago
121
                    .frame(width: 10, height: 10)
122
                Text("Offline")
123
                    .font(.caption.weight(.semibold))
124
                    .foregroundColor(.secondary)
125
            }
126
        }
127
        .frame(maxWidth: .infinity, alignment: .leading)
128
        .padding(18)
Bogdan Timofte authored a month ago
129
        .meterCard(tint: meterColor, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
Bogdan Timofte authored 2 months ago
130
    }
131

            
Bogdan Timofte authored 2 months ago
132
    private var identifiersCard: some View {
Bogdan Timofte authored 2 months ago
133
        VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored 2 months ago
134
            Text("Identifiers")
Bogdan Timofte authored 2 months ago
135
                .font(.headline)
Bogdan Timofte authored 2 months ago
136
            infoRow(label: "MAC Address", value: meterSummary.macAddress)
137
            if let advertisedName = meterSummary.advertisedName {
138
                infoRow(label: "Advertised as", value: advertisedName)
139
            }
Bogdan Timofte authored 2 months ago
140
        }
141
        .frame(maxWidth: .infinity, alignment: .leading)
142
        .padding(18)
143
        .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
144
    }
145

            
146
    private func infoRow(label: String, value: String) -> some View {
147
        HStack {
148
            Text(label)
149
            Spacer()
150
            Text(value)
151
                .foregroundColor(.secondary)
152
                .font(.caption)
153
        }
154
    }
155
}
156

            
157
struct MeterDetailView_Previews: PreviewProvider {
158
    static var previews: some View {
159
        MeterDetailView(
Bogdan Timofte authored 2 months ago
160
            meterSummary: AppData.MeterSummary(
Bogdan Timofte authored 2 months ago
161
                macAddress: "AA:BB:CC:DD:EE:FF",
162
                displayName: "Desk Meter",
163
                modelSummary: "UM25C",
164
                advertisedName: "UM25C-123",
165
                lastSeen: Date(),
166
                lastConnected: Date().addingTimeInterval(-3600),
167
                meter: nil
168
            )
169
        )
Bogdan Timofte authored a month ago
170
    }
171
}
172

            
173
struct MeterEditorSheetView: View {
174
    @EnvironmentObject private var appData: AppData
175
    @Environment(\.dismiss) private var dismiss
176

            
177
    let existingMeterSummary: AppData.MeterSummary?
178

            
179
    @State private var customName: String
180
    @State private var macAddress: String
181
    @State private var advertisedName: String
182
    @State private var selectedModel: Model
183

            
184
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
185
        self.existingMeterSummary = existingMeterSummary
186
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
187
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
188
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
189
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
190
    }
191

            
192
    var body: some View {
193
        NavigationView {
194
            Form {
Bogdan Timofte authored a month ago
195
                Section(
196
                    header: ContextInfoHeader(
197
                        title: "Identity",
198
                        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."
199
                    )
200
                ) {
Bogdan Timofte authored a month ago
201
                    TextField("Display name", text: $customName)
202
                    TextField("MAC Address", text: $macAddress)
203
                        .textInputAutocapitalization(.characters)
204
                        .disableAutocorrection(true)
205
                        .disabled(existingMeterSummary != nil)
206

            
207
                    Picker("Model", selection: $selectedModel) {
208
                        ForEach(Model.allCases, id: \.self) { model in
209
                            Text(model.canonicalName)
210
                                .tag(model)
211
                        }
212
                    }
213

            
214
                    TextField("Advertised name", text: $advertisedName)
215
                }
216
            }
217
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
218
            .navigationBarTitleDisplayMode(.inline)
219
            .toolbar {
220
                ToolbarItem(placement: .cancellationAction) {
221
                    Button("Cancel") {
222
                        dismiss()
223
                    }
224
                }
225
                ToolbarItem(placement: .confirmationAction) {
226
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
227
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
228
                        let didSave = appData.createKnownMeter(
229
                            macAddress: normalizedMAC,
230
                            customName: customName,
231
                            modelName: selectedModel.canonicalName,
232
                            advertisedName: advertisedName
233
                        )
234
                        if didSave {
235
                            dismiss()
236
                        }
237
                    }
238
                    .disabled(isSaveDisabled)
239
                }
240
            }
241
        }
242
        .navigationViewStyle(StackNavigationViewStyle())
243
    }
244

            
245
    private var isSaveDisabled: Bool {
246
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
247
    }
248

            
249
    private static func model(for summary: String?) -> Model {
250
        if summary?.contains("UM34C") == true {
251
            return .UM34C
252
        }
253
        if summary?.contains("TC66C") == true {
254
            return .TC66C
255
        }
256
        return .UM25C
Bogdan Timofte authored 2 months ago
257
    }
258
}