USB-Meter / USB Meter / Views / MeterDetailView.swift
Newer Older
320 lines | 12.543kb
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
                                ChargedDeviceIdentityLabelView(
140
                                    chargedDevice: chargedDevice,
141
                                    iconPointSize: 15
142
                                )
143
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
144
                                Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
145
                                    .font(.caption)
146
                                    .foregroundColor(.secondary)
147
                            }
148

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

            
161
    private var chargersCard: some View {
162
        let chargers = appData.chargers(for: meterSummary.macAddress)
163

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

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

            
184
                            VStack(alignment: .leading, spacing: 4) {
Bogdan Timofte authored a month ago
185
                                ChargedDeviceIdentityLabelView(
186
                                    chargedDevice: charger,
187
                                    iconPointSize: 15
188
                                )
189
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
190
                                Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
191
                                    .font(.caption)
192
                                    .foregroundColor(.secondary)
193
                            }
194

            
195
                            Spacer()
196
                        }
197
                    }
198
                    .buttonStyle(.plain)
199
                }
200
            }
201
        }
202
        .frame(maxWidth: .infinity, alignment: .leading)
203
        .padding(18)
204
        .meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
205
    }
206

            
Bogdan Timofte authored 2 months ago
207
    private func infoRow(label: String, value: String) -> some View {
208
        HStack {
209
            Text(label)
210
            Spacer()
211
            Text(value)
212
                .foregroundColor(.secondary)
213
                .font(.caption)
214
        }
215
    }
216
}
217

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

            
235
struct MeterEditorSheetView: View {
236
    @EnvironmentObject private var appData: AppData
237
    @Environment(\.dismiss) private var dismiss
238

            
239
    let existingMeterSummary: AppData.MeterSummary?
240

            
241
    @State private var customName: String
242
    @State private var macAddress: String
243
    @State private var advertisedName: String
244
    @State private var selectedModel: Model
245

            
246
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
247
        self.existingMeterSummary = existingMeterSummary
248
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
249
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
250
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
251
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
252
    }
253

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

            
269
                    Picker("Model", selection: $selectedModel) {
270
                        ForEach(Model.allCases, id: \.self) { model in
271
                            Text(model.canonicalName)
272
                                .tag(model)
273
                        }
274
                    }
275

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

            
307
    private var isSaveDisabled: Bool {
308
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
309
    }
310

            
311
    private static func model(for summary: String?) -> Model {
312
        if summary?.contains("UM34C") == true {
313
            return .UM34C
314
        }
315
        if summary?.contains("TC66C") == true {
316
            return .TC66C
317
        }
318
        return .UM25C
Bogdan Timofte authored 2 months ago
319
    }
320
}