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

            
3
struct MeterDetailView: View {
Bogdan Timofte authored a month ago
4
    @EnvironmentObject private var appData: AppData
5
    @Environment(\.dismiss) private var dismiss
6
    @State private var editorVisibility = false
7
    @State private var deleteConfirmationVisibility = false
8

            
Bogdan Timofte authored 2 months ago
9
    let meterSummary: AppData.MeterSummary
Bogdan Timofte authored 2 months ago
10

            
11
    var body: some View {
12
        ScrollView {
13
            VStack(spacing: 18) {
14
                headerCard
15
                statusCard
Bogdan Timofte authored 2 months ago
16
                identifiersCard
Bogdan Timofte authored a month ago
17
                chargedDevicesCard
18
                chargersCard
Bogdan Timofte authored 2 months ago
19
            }
20
            .padding()
21
        }
22
        .background(
23
            LinearGradient(
Bogdan Timofte authored 2 months ago
24
                colors: [meterSummary.tint.opacity(0.18), Color.clear],
Bogdan Timofte authored 2 months ago
25
                startPoint: .topLeading,
26
                endPoint: .bottomTrailing
27
            )
28
            .ignoresSafeArea()
29
        )
Bogdan Timofte authored 2 months ago
30
        .navigationTitle(meterSummary.displayName)
Bogdan Timofte authored a month ago
31
        .toolbar {
32
            ToolbarItemGroup(placement: .primaryAction) {
33
                Button("Edit") {
34
                    editorVisibility = true
35
                }
36
                Button(role: .destructive) {
37
                    deleteConfirmationVisibility = true
38
                } label: {
39
                    Image(systemName: "trash")
40
                }
41
            }
42
        }
43
        .sheet(isPresented: $editorVisibility) {
44
            MeterEditorSheetView(existingMeterSummary: meterSummary)
45
                .environmentObject(appData)
46
        }
47
        .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
48
            Button("Delete", role: .destructive) {
49
                if appData.deleteMeter(macAddress: meterSummary.macAddress) {
50
                    dismiss()
51
                }
52
            }
53
            Button("Cancel", role: .cancel) {}
54
        } message: {
55
            Text("This removes the stored meter entry and its saved metadata from the sidebar until the meter is discovered again.")
56
        }
Bogdan Timofte authored 2 months ago
57
    }
58

            
59
    private var headerCard: some View {
60
        VStack(alignment: .leading, spacing: 8) {
Bogdan Timofte authored 2 months ago
61
            Text(meterSummary.displayName)
Bogdan Timofte authored 2 months ago
62
                .font(.title2.weight(.semibold))
Bogdan Timofte authored 2 months ago
63
            Text(meterSummary.modelSummary)
Bogdan Timofte authored 2 months ago
64
                .font(.subheadline)
65
                .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
66
            if let advertisedName = meterSummary.advertisedName {
Bogdan Timofte authored 2 months ago
67
                Text("Advertised as " + advertisedName)
68
                    .font(.caption2)
69
                    .foregroundColor(.secondary)
70
            }
71
        }
72
        .frame(maxWidth: .infinity, alignment: .leading)
73
        .padding(18)
Bogdan Timofte authored 2 months ago
74
        .meterCard(tint: meterSummary.tint, fillOpacity: 0.22, strokeOpacity: 0.28, cornerRadius: 20)
Bogdan Timofte authored 2 months ago
75
    }
76

            
77
    private var statusCard: some View {
78
        VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
79
            HStack(spacing: 8) {
80
                Text("Status")
81
                    .font(.headline)
82
                ContextInfoButton(
83
                    title: "Status",
84
                    message: "The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics."
85
                )
86
            }
Bogdan Timofte authored 2 months ago
87
            HStack(spacing: 8) {
88
                Circle()
Bogdan Timofte authored 2 months ago
89
                    .fill(meterSummary.tint)
Bogdan Timofte authored 2 months ago
90
                    .frame(width: 10, height: 10)
91
                Text("Offline")
92
                    .font(.caption.weight(.semibold))
93
                    .foregroundColor(.secondary)
94
            }
95
        }
96
        .frame(maxWidth: .infinity, alignment: .leading)
97
        .padding(18)
Bogdan Timofte authored 2 months ago
98
        .meterCard(tint: meterSummary.tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
Bogdan Timofte authored 2 months ago
99
    }
100

            
Bogdan Timofte authored 2 months ago
101
    private var identifiersCard: some View {
Bogdan Timofte authored 2 months ago
102
        VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored 2 months ago
103
            Text("Identifiers")
Bogdan Timofte authored 2 months ago
104
                .font(.headline)
Bogdan Timofte authored 2 months ago
105
            infoRow(label: "MAC Address", value: meterSummary.macAddress)
106
            if let advertisedName = meterSummary.advertisedName {
107
                infoRow(label: "Advertised as", value: advertisedName)
108
            }
Bogdan Timofte authored 2 months ago
109
        }
110
        .frame(maxWidth: .infinity, alignment: .leading)
111
        .padding(18)
112
        .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
113
    }
114

            
Bogdan Timofte authored a month ago
115
    private var chargedDevicesCard: some View {
116
        let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress)
117

            
118
        return VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
119
            HStack(spacing: 8) {
120
                Text("Devices")
121
                    .font(.headline)
122
                ContextInfoButton(
123
                    title: "Devices",
124
                    message: "Link devices to this meter from Charge Record to keep capacity learning and charge curves tied to the right hardware."
125
                )
126
            }
Bogdan Timofte authored a month ago
127

            
128
            if chargedDevices.isEmpty {
Bogdan Timofte authored a month ago
129
                Text("No devices linked yet.")
Bogdan Timofte authored a month ago
130
                    .font(.caption)
131
                    .foregroundColor(.secondary)
132
            } else {
133
                ForEach(chargedDevices.prefix(3)) { chargedDevice in
134
                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
135
                        HStack(spacing: 12) {
136
                            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
137

            
138
                            VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
139
                                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
Bogdan Timofte authored a month ago
140
                                    .font(.subheadline.weight(.semibold))
141
                                Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
142
                                    .font(.caption)
143
                                    .foregroundColor(.secondary)
144
                            }
145

            
146
                            Spacer()
147
                        }
148
                    }
149
                    .buttonStyle(.plain)
150
                }
151
            }
152
        }
153
        .frame(maxWidth: .infinity, alignment: .leading)
154
        .padding(18)
155
        .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
156
    }
157

            
158
    private var chargersCard: some View {
159
        let chargers = appData.chargers(for: meterSummary.macAddress)
160

            
161
        return VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
162
            HStack(spacing: 8) {
163
                Text("Chargers")
164
                    .font(.headline)
165
                ContextInfoButton(
166
                    title: "Chargers",
167
                    message: "Link chargers to this meter for wireless sessions so the app can keep charger-specific learning and efficiency data separate."
168
                )
169
            }
Bogdan Timofte authored a month ago
170

            
171
            if chargers.isEmpty {
Bogdan Timofte authored a month ago
172
                Text("No chargers linked yet.")
Bogdan Timofte authored a month ago
173
                    .font(.caption)
174
                    .foregroundColor(.secondary)
175
            } else {
176
                ForEach(chargers.prefix(3)) { charger in
177
                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: charger.id)) {
178
                        HStack(spacing: 12) {
179
                            ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
180

            
181
                            VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
182
                                Label(charger.name, systemImage: charger.identitySymbolName)
Bogdan Timofte authored a month ago
183
                                    .font(.subheadline.weight(.semibold))
184
                                Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
185
                                    .font(.caption)
186
                                    .foregroundColor(.secondary)
187
                            }
188

            
189
                            Spacer()
190
                        }
191
                    }
192
                    .buttonStyle(.plain)
193
                }
194
            }
195
        }
196
        .frame(maxWidth: .infinity, alignment: .leading)
197
        .padding(18)
198
        .meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
199
    }
200

            
Bogdan Timofte authored 2 months ago
201
    private func infoRow(label: String, value: String) -> some View {
202
        HStack {
203
            Text(label)
204
            Spacer()
205
            Text(value)
206
                .foregroundColor(.secondary)
207
                .font(.caption)
208
        }
209
    }
210
}
211

            
212
struct MeterDetailView_Previews: PreviewProvider {
213
    static var previews: some View {
214
        MeterDetailView(
Bogdan Timofte authored 2 months ago
215
            meterSummary: AppData.MeterSummary(
Bogdan Timofte authored 2 months ago
216
                macAddress: "AA:BB:CC:DD:EE:FF",
217
                displayName: "Desk Meter",
218
                modelSummary: "UM25C",
219
                advertisedName: "UM25C-123",
220
                lastSeen: Date(),
221
                lastConnected: Date().addingTimeInterval(-3600),
222
                meter: nil
223
            )
224
        )
Bogdan Timofte authored a month ago
225
        .environmentObject(appData)
226
    }
227
}
228

            
229
struct MeterEditorSheetView: View {
230
    @EnvironmentObject private var appData: AppData
231
    @Environment(\.dismiss) private var dismiss
232

            
233
    let existingMeterSummary: AppData.MeterSummary?
234

            
235
    @State private var customName: String
236
    @State private var macAddress: String
237
    @State private var advertisedName: String
238
    @State private var selectedModel: Model
239

            
240
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
241
        self.existingMeterSummary = existingMeterSummary
242
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
243
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
244
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
245
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
246
    }
247

            
248
    var body: some View {
249
        NavigationView {
250
            Form {
Bogdan Timofte authored a month ago
251
                Section(
252
                    header: ContextInfoHeader(
253
                        title: "Identity",
254
                        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."
255
                    )
256
                ) {
Bogdan Timofte authored a month ago
257
                    TextField("Display name", text: $customName)
258
                    TextField("MAC Address", text: $macAddress)
259
                        .textInputAutocapitalization(.characters)
260
                        .disableAutocorrection(true)
261
                        .disabled(existingMeterSummary != nil)
262

            
263
                    Picker("Model", selection: $selectedModel) {
264
                        ForEach(Model.allCases, id: \.self) { model in
265
                            Text(model.canonicalName)
266
                                .tag(model)
267
                        }
268
                    }
269

            
270
                    TextField("Advertised name", text: $advertisedName)
271
                }
272
            }
273
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
274
            .navigationBarTitleDisplayMode(.inline)
275
            .toolbar {
276
                ToolbarItem(placement: .cancellationAction) {
277
                    Button("Cancel") {
278
                        dismiss()
279
                    }
280
                }
281
                ToolbarItem(placement: .confirmationAction) {
282
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
283
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
284
                        let didSave = appData.createKnownMeter(
285
                            macAddress: normalizedMAC,
286
                            customName: customName,
287
                            modelName: selectedModel.canonicalName,
288
                            advertisedName: advertisedName
289
                        )
290
                        if didSave {
291
                            dismiss()
292
                        }
293
                    }
294
                    .disabled(isSaveDisabled)
295
                }
296
            }
297
        }
298
        .navigationViewStyle(StackNavigationViewStyle())
299
    }
300

            
301
    private var isSaveDisabled: Bool {
302
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
303
    }
304

            
305
    private static func model(for summary: String?) -> Model {
306
        if summary?.contains("UM34C") == true {
307
            return .UM34C
308
        }
309
        if summary?.contains("TC66C") == true {
310
            return .TC66C
311
        }
312
        return .UM25C
Bogdan Timofte authored 2 months ago
313
    }
314
}