USB-Meter / USB Meter / Views / MeterDetailView.swift
Newer Older
300 lines | 11.9kb
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) {
79
            Text("Status")
80
                .font(.headline)
81
            HStack(spacing: 8) {
82
                Circle()
Bogdan Timofte authored 2 months ago
83
                    .fill(meterSummary.tint)
Bogdan Timofte authored 2 months ago
84
                    .frame(width: 10, height: 10)
85
                Text("Offline")
86
                    .font(.caption.weight(.semibold))
87
                    .foregroundColor(.secondary)
88
            }
89
            Text("The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics.")
90
                .font(.caption)
91
                .foregroundColor(.secondary)
92
        }
93
        .frame(maxWidth: .infinity, alignment: .leading)
94
        .padding(18)
Bogdan Timofte authored 2 months ago
95
        .meterCard(tint: meterSummary.tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
Bogdan Timofte authored 2 months ago
96
    }
97

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

            
Bogdan Timofte authored a month ago
112
    private var chargedDevicesCard: some View {
113
        let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress)
114

            
115
        return VStack(alignment: .leading, spacing: 10) {
116
            Text("Devices")
117
                .font(.headline)
118

            
119
            if chargedDevices.isEmpty {
120
                Text("No devices are linked to this meter yet. Connect it, open Charge Record, and select the device being charged to start learning capacity and charge curves.")
121
                    .font(.caption)
122
                    .foregroundColor(.secondary)
123
            } else {
124
                ForEach(chargedDevices.prefix(3)) { chargedDevice in
125
                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
126
                        HStack(spacing: 12) {
127
                            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
128

            
129
                            VStack(alignment: .leading, spacing: 4) {
130
                                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
131
                                    .font(.subheadline.weight(.semibold))
132
                                Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
133
                                    .font(.caption)
134
                                    .foregroundColor(.secondary)
135
                            }
136

            
137
                            Spacer()
138
                        }
139
                    }
140
                    .buttonStyle(.plain)
141
                }
142
            }
143
        }
144
        .frame(maxWidth: .infinity, alignment: .leading)
145
        .padding(18)
146
        .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
147
    }
148

            
149
    private var chargersCard: some View {
150
        let chargers = appData.chargers(for: meterSummary.macAddress)
151

            
152
        return VStack(alignment: .leading, spacing: 10) {
153
            Text("Chargers")
154
                .font(.headline)
155

            
156
            if chargers.isEmpty {
157
                Text("No chargers are linked to this meter yet. Pick one from Charge Record when you monitor a wireless charging session.")
158
                    .font(.caption)
159
                    .foregroundColor(.secondary)
160
            } else {
161
                ForEach(chargers.prefix(3)) { charger in
162
                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: charger.id)) {
163
                        HStack(spacing: 12) {
164
                            ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
165

            
166
                            VStack(alignment: .leading, spacing: 4) {
167
                                Label(charger.name, systemImage: charger.deviceClass.symbolName)
168
                                    .font(.subheadline.weight(.semibold))
169
                                Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
170
                                    .font(.caption)
171
                                    .foregroundColor(.secondary)
172
                            }
173

            
174
                            Spacer()
175
                        }
176
                    }
177
                    .buttonStyle(.plain)
178
                }
179
            }
180
        }
181
        .frame(maxWidth: .infinity, alignment: .leading)
182
        .padding(18)
183
        .meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
184
    }
185

            
Bogdan Timofte authored 2 months ago
186
    private func infoRow(label: String, value: String) -> some View {
187
        HStack {
188
            Text(label)
189
            Spacer()
190
            Text(value)
191
                .foregroundColor(.secondary)
192
                .font(.caption)
193
        }
194
    }
195
}
196

            
197
struct MeterDetailView_Previews: PreviewProvider {
198
    static var previews: some View {
199
        MeterDetailView(
Bogdan Timofte authored 2 months ago
200
            meterSummary: AppData.MeterSummary(
Bogdan Timofte authored 2 months ago
201
                macAddress: "AA:BB:CC:DD:EE:FF",
202
                displayName: "Desk Meter",
203
                modelSummary: "UM25C",
204
                advertisedName: "UM25C-123",
205
                lastSeen: Date(),
206
                lastConnected: Date().addingTimeInterval(-3600),
207
                meter: nil
208
            )
209
        )
Bogdan Timofte authored a month ago
210
        .environmentObject(appData)
211
    }
212
}
213

            
214
struct MeterEditorSheetView: View {
215
    @EnvironmentObject private var appData: AppData
216
    @Environment(\.dismiss) private var dismiss
217

            
218
    let existingMeterSummary: AppData.MeterSummary?
219

            
220
    @State private var customName: String
221
    @State private var macAddress: String
222
    @State private var advertisedName: String
223
    @State private var selectedModel: Model
224

            
225
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
226
        self.existingMeterSummary = existingMeterSummary
227
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
228
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
229
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
230
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
231
    }
232

            
233
    var body: some View {
234
        NavigationView {
235
            Form {
236
                Section(header: Text("Identity")) {
237
                    TextField("Display name", text: $customName)
238
                    TextField("MAC Address", text: $macAddress)
239
                        .textInputAutocapitalization(.characters)
240
                        .disableAutocorrection(true)
241
                        .disabled(existingMeterSummary != nil)
242

            
243
                    Picker("Model", selection: $selectedModel) {
244
                        ForEach(Model.allCases, id: \.self) { model in
245
                            Text(model.canonicalName)
246
                                .tag(model)
247
                        }
248
                    }
249

            
250
                    TextField("Advertised name", text: $advertisedName)
251
                }
252

            
253
                Section {
254
                    Text("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
                        .font(.footnote)
256
                        .foregroundColor(.secondary)
257
                }
258
            }
259
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
260
            .navigationBarTitleDisplayMode(.inline)
261
            .toolbar {
262
                ToolbarItem(placement: .cancellationAction) {
263
                    Button("Cancel") {
264
                        dismiss()
265
                    }
266
                }
267
                ToolbarItem(placement: .confirmationAction) {
268
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
269
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
270
                        let didSave = appData.createKnownMeter(
271
                            macAddress: normalizedMAC,
272
                            customName: customName,
273
                            modelName: selectedModel.canonicalName,
274
                            advertisedName: advertisedName
275
                        )
276
                        if didSave {
277
                            dismiss()
278
                        }
279
                    }
280
                    .disabled(isSaveDisabled)
281
                }
282
            }
283
        }
284
        .navigationViewStyle(StackNavigationViewStyle())
285
    }
286

            
287
    private var isSaveDisabled: Bool {
288
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
289
    }
290

            
291
    private static func model(for summary: String?) -> Model {
292
        if summary?.contains("UM34C") == true {
293
            return .UM34C
294
        }
295
        if summary?.contains("TC66C") == true {
296
            return .TC66C
297
        }
298
        return .UM25C
Bogdan Timofte authored 2 months ago
299
    }
300
}