Newer Older
851 lines | 31.443kb
Bogdan Timofte authored 2 months ago
1
//
2
//  MeterView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 04/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
// MARK: Parent frame https://stackoverflow.com/questions/56832865/how-to-access-parents-frame-in-swiftui
9

            
10
import SwiftUI
11
import CoreBluetooth
12

            
13
struct MeterView: View {
Bogdan Timofte authored 2 months ago
14
    private struct TabBarStyle {
15
        let horizontalPadding: CGFloat
16
        let topPadding: CGFloat
17
        let bottomPadding: CGFloat
18
        let chipHorizontalPadding: CGFloat
19
        let chipVerticalPadding: CGFloat
20
        let outerPadding: CGFloat
21
        let barBackgroundOpacity: CGFloat
22
        let materialOpacity: CGFloat
23
        let shadowOpacity: CGFloat
24
        let floatingInset: CGFloat
25

            
26
        static let portrait = TabBarStyle(
27
            horizontalPadding: 16,
28
            topPadding: 10,
29
            bottomPadding: 8,
30
            chipHorizontalPadding: 10,
31
            chipVerticalPadding: 7,
32
            outerPadding: 6,
33
            barBackgroundOpacity: 0.10,
34
            materialOpacity: 0.78,
35
            shadowOpacity: 0,
36
            floatingInset: 0
37
        )
38

            
Bogdan Timofte authored 2 months ago
39
        static let portraitCompact = TabBarStyle(
40
            horizontalPadding: 16,
41
            topPadding: 10,
42
            bottomPadding: 8,
43
            chipHorizontalPadding: 12,
44
            chipVerticalPadding: 10,
45
            outerPadding: 6,
46
            barBackgroundOpacity: 0.14,
47
            materialOpacity: 0.90,
48
            shadowOpacity: 0,
49
            floatingInset: 0
50
        )
51

            
Bogdan Timofte authored 2 months ago
52
        static let landscapeInline = TabBarStyle(
53
            horizontalPadding: 12,
54
            topPadding: 10,
55
            bottomPadding: 8,
56
            chipHorizontalPadding: 10,
57
            chipVerticalPadding: 7,
58
            outerPadding: 6,
59
            barBackgroundOpacity: 0.10,
60
            materialOpacity: 0.78,
61
            shadowOpacity: 0,
62
            floatingInset: 0
63
        )
64

            
65
        static let landscapeFloating = TabBarStyle(
66
            horizontalPadding: 16,
67
            topPadding: 10,
68
            bottomPadding: 0,
69
            chipHorizontalPadding: 11,
70
            chipVerticalPadding: 11,
71
            outerPadding: 7,
Bogdan Timofte authored 2 months ago
72
            barBackgroundOpacity: 0.16,
73
            materialOpacity: 0.88,
74
            shadowOpacity: 0.12,
Bogdan Timofte authored 2 months ago
75
            floatingInset: 12
76
        )
77
    }
78

            
Bogdan Timofte authored 2 months ago
79
    private enum MeterTab: String, Hashable {
Bogdan Timofte authored 2 months ago
80
        case home
Bogdan Timofte authored 2 months ago
81
        case live
82
        case chart
Bogdan Timofte authored a month ago
83
        case chargeRecord
84
        case dataGroups
Bogdan Timofte authored 2 months ago
85
        case settings
Bogdan Timofte authored 2 months ago
86

            
87
        var systemImage: String {
88
            switch self {
Bogdan Timofte authored 2 months ago
89
            case .home: return "house.fill"
Bogdan Timofte authored 2 months ago
90
            case .live: return "waveform.path.ecg"
91
            case .chart: return "chart.xyaxis.line"
Bogdan Timofte authored a month ago
92
            case .chargeRecord: return "gauge.with.dots.needle.50percent"
93
            case .dataGroups: return "square.grid.2x2.fill"
Bogdan Timofte authored 2 months ago
94
            case .settings: return "gearshape.fill"
Bogdan Timofte authored 2 months ago
95
            }
96
        }
97
    }
Bogdan Timofte authored 2 months ago
98

            
99
    @EnvironmentObject private var meter: Meter
Bogdan Timofte authored 2 months ago
100
    @Environment(\.dismiss) private var dismiss
101

            
102
    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
Bogdan Timofte authored 2 months ago
103
    #if os(iOS)
104
    private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone
105
    #else
106
    private static let isPhone: Bool = false
107
    #endif
Bogdan Timofte authored a month ago
108

            
Bogdan Timofte authored 2 months ago
109
    @State private var selectedMeterTab: MeterTab = .home
110
    @State private var landscapeTabBarHeight: CGFloat = 0
Bogdan Timofte authored 2 months ago
111

            
Bogdan Timofte authored a month ago
112
    // Offline mode state
113
    private enum OfflineTab: String { case info, settings }
114
    @State private var selectedOfflineTab: OfflineTab = .info
115
    @State private var offlineEditingName: Bool = false
116
    @State private var offlineName: String = ""
117
    @State private var offlineDeleteConfirmation: Bool = false
118
    @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius
119

            
120
    private let offlineSummary: AppData.MeterSummary?
121

            
122
    init() { offlineSummary = nil }
123
    init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary }
124

            
Bogdan Timofte authored 2 months ago
125
    var body: some View {
Bogdan Timofte authored a month ago
126
        if let summary = offlineSummary {
127
            offlineBody(summary: summary)
128
        } else {
129
            liveBody
130
        }
131
    }
132

            
133
    private var liveBody: some View {
Bogdan Timofte authored 2 months ago
134
        GeometryReader { proxy in
135
            let landscape = isLandscape(size: proxy.size)
Bogdan Timofte authored 2 months ago
136
            let usesOverlayTabBar = landscape && Self.isPhone
Bogdan Timofte authored 2 months ago
137
            let tabBarStyle = tabBarStyle(
138
                for: landscape,
139
                usesOverlayTabBar: usesOverlayTabBar,
140
                size: proxy.size
141
            )
Bogdan Timofte authored a month ago
142
            let tabBarPresentation = tabBarPresentation(
143
                for: proxy.size,
144
                usesOverlayTabBar: usesOverlayTabBar
145
            )
Bogdan Timofte authored 2 months ago
146

            
Bogdan Timofte authored 2 months ago
147
            VStack(spacing: 0) {
148
                Group {
149
                    if landscape {
Bogdan Timofte authored 2 months ago
150
                        landscapeDeck(
151
                            size: proxy.size,
152
                            usesOverlayTabBar: usesOverlayTabBar,
Bogdan Timofte authored a month ago
153
                            tabBarStyle: tabBarStyle,
154
                            tabBarPresentation: tabBarPresentation
Bogdan Timofte authored 2 months ago
155
                        )
Bogdan Timofte authored 2 months ago
156
                    } else {
Bogdan Timofte authored a month ago
157
                        portraitContent(
158
                            size: proxy.size,
159
                            tabBarStyle: tabBarStyle,
160
                            tabBarPresentation: tabBarPresentation
161
                        )
Bogdan Timofte authored 2 months ago
162
                    }
Bogdan Timofte authored 2 months ago
163
                }
Bogdan Timofte authored 2 months ago
164
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 months ago
165
            }
166
        }
167
        .background(meterBackground)
Bogdan Timofte authored a month ago
168
        .navigationTitle(meter.name.isEmpty ? "Meter" : meter.name)
169
        .navigationBarTitleDisplayMode(.inline)
170
        .toolbar {
171
            ToolbarItemGroup(placement: .navigationBarTrailing) {
172
                MeterConnectionToolbarButton(
173
                    operationalState: meter.operationalState,
174
                    showsTitle: false,
175
                    connectAction: { meter.connect() },
176
                    disconnectAction: { meter.disconnect() }
177
                )
178
                .font(.body.weight(.semibold))
179
                if meter.operationalState > .notPresent {
180
                    RSSIView(RSSI: meter.btSerial.averageRSSI)
181
                        .frame(width: 18, height: 18)
182
                }
Bogdan Timofte authored 2 months ago
183
            }
184
        }
Bogdan Timofte authored 2 months ago
185
        .onChange(of: selectedMeterTab) { newTab in
186
            meter.preferredTabIdentifier = newTab.rawValue
187
        }
Bogdan Timofte authored 2 months ago
188
    }
189

            
Bogdan Timofte authored a month ago
190
    private func portraitContent(
191
        size: CGSize,
192
        tabBarStyle: TabBarStyle,
193
        tabBarPresentation: AdaptiveTabBarPresentation
194
    ) -> some View {
195
        portraitSegmentedDeck(
196
            size: size,
197
            tabBarStyle: tabBarStyle,
198
            tabBarPresentation: tabBarPresentation
199
        )
Bogdan Timofte authored 2 months ago
200
    }
201

            
202
    @ViewBuilder
Bogdan Timofte authored a month ago
203
    private func landscapeDeck(
204
        size: CGSize,
205
        usesOverlayTabBar: Bool,
206
        tabBarStyle: TabBarStyle,
207
        tabBarPresentation: AdaptiveTabBarPresentation
208
    ) -> some View {
Bogdan Timofte authored 2 months ago
209
        if usesOverlayTabBar {
Bogdan Timofte authored a month ago
210
            landscapeOverlaySegmentedDeck(
211
                size: size,
212
                tabBarStyle: tabBarStyle,
213
                tabBarPresentation: tabBarPresentation
214
            )
Bogdan Timofte authored 2 months ago
215
        } else {
Bogdan Timofte authored a month ago
216
            landscapeSegmentedDeck(
217
                size: size,
218
                tabBarStyle: tabBarStyle,
219
                tabBarPresentation: tabBarPresentation
220
            )
Bogdan Timofte authored 2 months ago
221
        }
Bogdan Timofte authored 2 months ago
222
    }
223

            
Bogdan Timofte authored a month ago
224
    private func landscapeOverlaySegmentedDeck(
225
        size: CGSize,
226
        tabBarStyle: TabBarStyle,
227
        tabBarPresentation: AdaptiveTabBarPresentation
228
    ) -> some View {
Bogdan Timofte authored 2 months ago
229
        ZStack(alignment: .top) {
230
            landscapeSegmentedContent(size: size)
231
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
232
                .padding(.top, landscapeContentTopPadding(for: tabBarStyle))
Bogdan Timofte authored 2 months ago
233
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
234
                .transition(.opacity.combined(with: .move(edge: .trailing)))
235

            
Bogdan Timofte authored a month ago
236
            segmentedTabBar(
237
                style: tabBarStyle,
238
                presentation: tabBarPresentation,
239
                showsConnectionAction: !Self.isMacIPadApp
240
            )
Bogdan Timofte authored 2 months ago
241
        }
Bogdan Timofte authored 2 months ago
242
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
243
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
244
        .onAppear {
Bogdan Timofte authored 2 months ago
245
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
246
        }
247
        .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
248
            if height > 0 {
249
                landscapeTabBarHeight = height
250
            }
251
        }
Bogdan Timofte authored 2 months ago
252
    }
253

            
Bogdan Timofte authored a month ago
254
    private func landscapeSegmentedDeck(
255
        size: CGSize,
256
        tabBarStyle: TabBarStyle,
257
        tabBarPresentation: AdaptiveTabBarPresentation
258
    ) -> some View {
Bogdan Timofte authored 2 months ago
259
        VStack(spacing: 0) {
Bogdan Timofte authored a month ago
260
            segmentedTabBar(
261
                style: tabBarStyle,
262
                presentation: tabBarPresentation,
263
                showsConnectionAction: !Self.isMacIPadApp
264
            )
Bogdan Timofte authored 2 months ago
265

            
266
            landscapeSegmentedContent(size: size)
267
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
268
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
269
                .transition(.opacity.combined(with: .move(edge: .trailing)))
270
        }
Bogdan Timofte authored 2 months ago
271
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
272
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
273
        .onAppear {
Bogdan Timofte authored 2 months ago
274
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
275
        }
276
    }
277

            
Bogdan Timofte authored a month ago
278
    private func portraitSegmentedDeck(
279
        size: CGSize,
280
        tabBarStyle: TabBarStyle,
281
        tabBarPresentation: AdaptiveTabBarPresentation
282
    ) -> some View {
Bogdan Timofte authored 2 months ago
283
        VStack(spacing: 0) {
Bogdan Timofte authored a month ago
284
            segmentedTabBar(
285
                style: tabBarStyle,
286
                presentation: tabBarPresentation
287
            )
Bogdan Timofte authored 2 months ago
288

            
289
            portraitSegmentedContent(size: size)
290
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
291
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
292
                .transition(.opacity.combined(with: .move(edge: .trailing)))
293
        }
Bogdan Timofte authored 2 months ago
294
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
295
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
296
        .onAppear {
Bogdan Timofte authored 2 months ago
297
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
298
        }
299
    }
300

            
Bogdan Timofte authored a month ago
301
    private func segmentedTabBar(
302
        style: TabBarStyle,
303
        presentation: AdaptiveTabBarPresentation,
304
        showsConnectionAction: Bool = false
305
    ) -> some View {
Bogdan Timofte authored 2 months ago
306
        let isFloating = style.floatingInset > 0
Bogdan Timofte authored a month ago
307
        let cornerRadius = presentation.showsTitles ? 14.0 : 22.0
Bogdan Timofte authored 2 months ago
308
        let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary
309
        let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12)
Bogdan Timofte authored 2 months ago
310

            
311
        return HStack {
Bogdan Timofte authored 2 months ago
312
            Spacer(minLength: 0)
313

            
314
            HStack(spacing: 8) {
315
                ForEach(availableMeterTabs, id: \.self) { tab in
Bogdan Timofte authored 2 months ago
316
                    let isSelected = displayedMeterTab == tab
Bogdan Timofte authored a month ago
317
                    let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable
Bogdan Timofte authored 2 months ago
318

            
319
                    Button {
320
                        withAnimation(.easeInOut(duration: 0.2)) {
321
                            selectedMeterTab = tab
322
                        }
323
                    } label: {
324
                        HStack(spacing: 6) {
325
                            Image(systemName: tab.systemImage)
326
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
327
                            if presentation.showsTitles {
Bogdan Timofte authored a month ago
328
                                Text(title(for: tab))
Bogdan Timofte authored 2 months ago
329
                                    .font(.subheadline.weight(.semibold))
330
                                    .lineLimit(1)
331
                            }
Bogdan Timofte authored 2 months ago
332
                        }
Bogdan Timofte authored 2 months ago
333
                        .foregroundColor(
334
                            isSelected
335
                            ? .white
Bogdan Timofte authored a month ago
336
                            : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor)
Bogdan Timofte authored 2 months ago
337
                        )
338
                        .padding(.horizontal, style.chipHorizontalPadding)
339
                        .padding(.vertical, style.chipVerticalPadding)
Bogdan Timofte authored 2 months ago
340
                        .frame(maxWidth: .infinity)
341
                        .background(
342
                            Capsule()
Bogdan Timofte authored 2 months ago
343
                                .fill(
344
                                    isSelected
345
                                    ? meter.color.opacity(isFloating ? 0.94 : 1)
Bogdan Timofte authored a month ago
346
                                    : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill)
Bogdan Timofte authored 2 months ago
347
                                )
Bogdan Timofte authored 2 months ago
348
                        )
Bogdan Timofte authored 2 months ago
349
                    }
Bogdan Timofte authored 2 months ago
350
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
351
                    .accessibilityLabel(title(for: tab))
Bogdan Timofte authored 2 months ago
352
                }
353
            }
Bogdan Timofte authored a month ago
354
            .frame(maxWidth: presentation.maxWidth)
Bogdan Timofte authored 2 months ago
355
            .padding(style.outerPadding)
Bogdan Timofte authored 2 months ago
356
            .background(
Bogdan Timofte authored 2 months ago
357
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
358
                    .fill(
359
                        isFloating
360
                        ? LinearGradient(
361
                            colors: [
Bogdan Timofte authored 2 months ago
362
                                Color.white.opacity(0.76),
363
                                Color.white.opacity(0.52)
Bogdan Timofte authored 2 months ago
364
                            ],
365
                            startPoint: .topLeading,
366
                            endPoint: .bottomTrailing
367
                        )
368
                        : LinearGradient(
369
                            colors: [
370
                                Color.secondary.opacity(style.barBackgroundOpacity),
371
                                Color.secondary.opacity(style.barBackgroundOpacity)
372
                            ],
373
                            startPoint: .topLeading,
374
                            endPoint: .bottomTrailing
375
                        )
376
                    )
Bogdan Timofte authored 2 months ago
377
            )
Bogdan Timofte authored 2 months ago
378
            .overlay {
379
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
380
                    .stroke(
Bogdan Timofte authored 2 months ago
381
                        isFloating ? Color.black.opacity(0.08) : Color.clear,
Bogdan Timofte authored 2 months ago
382
                        lineWidth: 1
383
                    )
384
            }
385
            .background {
Bogdan Timofte authored 2 months ago
386
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
387
                    .fill(.ultraThinMaterial)
388
                    .opacity(style.materialOpacity)
Bogdan Timofte authored 2 months ago
389
            }
390
            .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12)
Bogdan Timofte authored 2 months ago
391

            
392
            Spacer(minLength: 0)
393
        }
Bogdan Timofte authored 2 months ago
394
        .padding(.horizontal, style.horizontalPadding)
395
        .padding(.top, style.topPadding)
396
        .padding(.bottom, style.bottomPadding)
Bogdan Timofte authored 2 months ago
397
        .background(
Bogdan Timofte authored 2 months ago
398
            GeometryReader { geometry in
399
                Color.clear
400
                    .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height)
401
            }
Bogdan Timofte authored 2 months ago
402
        )
Bogdan Timofte authored 2 months ago
403
        .padding(.horizontal, style.floatingInset)
404
        .background {
405
            if style.floatingInset == 0 {
406
                Rectangle()
407
                    .fill(.ultraThinMaterial)
408
                    .opacity(style.materialOpacity)
409
                    .ignoresSafeArea(edges: .top)
410
            }
411
        }
Bogdan Timofte authored 2 months ago
412
        .overlay(alignment: .bottom) {
Bogdan Timofte authored 2 months ago
413
            if style.floatingInset == 0 {
414
                Rectangle()
415
                    .fill(Color.secondary.opacity(0.12))
416
                    .frame(height: 1)
417
            }
418
        }
419
        .overlay(alignment: .trailing) {
420
            if showsConnectionAction {
421
                MeterConnectionToolbarButton(
422
                    operationalState: meter.operationalState,
423
                    showsTitle: false,
424
                    connectAction: { meter.connect() },
425
                    disconnectAction: { meter.disconnect() }
426
                )
427
                .font(.title3.weight(.semibold))
428
                .padding(.trailing, style.horizontalPadding + style.floatingInset + 4)
429
                .padding(.top, style.topPadding)
430
                .padding(.bottom, style.bottomPadding)
431
            }
Bogdan Timofte authored 2 months ago
432
        }
Bogdan Timofte authored 2 months ago
433
    }
434

            
Bogdan Timofte authored 2 months ago
435
    @ViewBuilder
436
    private func landscapeSegmentedContent(size: CGSize) -> some View {
Bogdan Timofte authored 2 months ago
437
        switch displayedMeterTab {
Bogdan Timofte authored 2 months ago
438
        case .home:
Bogdan Timofte authored a month ago
439
            MeterHomeTabView(
440
                size: size,
441
                isLandscape: true,
442
                showChargeRecordTab: {
443
                    withAnimation(.easeInOut(duration: 0.22)) {
444
                        selectedMeterTab = .chargeRecord
445
                    }
446
                },
447
                showDataGroupsTab: {
448
                    withAnimation(.easeInOut(duration: 0.22)) {
449
                        selectedMeterTab = .dataGroups
450
                    }
451
                }
452
            )
Bogdan Timofte authored 2 months ago
453
        case .live:
Bogdan Timofte authored 2 months ago
454
            MeterLiveTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 months ago
455
        case .chart:
Bogdan Timofte authored 2 months ago
456
            MeterChartTabView(size: size, isLandscape: true)
Bogdan Timofte authored a month ago
457
        case .chargeRecord:
Bogdan Timofte authored a month ago
458
            MeterChargeRecordTabView().equatable()
Bogdan Timofte authored a month ago
459
        case .dataGroups:
460
            MeterDataGroupsTabView()
Bogdan Timofte authored 2 months ago
461
        case .settings:
Bogdan Timofte authored 2 months ago
462
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
463
                withAnimation(.easeInOut(duration: 0.22)) {
Bogdan Timofte authored 2 months ago
464
                    selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
465
                }
466
            }
Bogdan Timofte authored 2 months ago
467
        }
468
    }
Bogdan Timofte authored 2 months ago
469

            
Bogdan Timofte authored 2 months ago
470
    @ViewBuilder
471
    private func portraitSegmentedContent(size: CGSize) -> some View {
Bogdan Timofte authored 2 months ago
472
        switch displayedMeterTab {
Bogdan Timofte authored 2 months ago
473
        case .home:
Bogdan Timofte authored a month ago
474
            MeterHomeTabView(
475
                size: size,
476
                isLandscape: false,
477
                showChargeRecordTab: {
478
                    withAnimation(.easeInOut(duration: 0.22)) {
479
                        selectedMeterTab = .chargeRecord
480
                    }
481
                },
482
                showDataGroupsTab: {
483
                    withAnimation(.easeInOut(duration: 0.22)) {
484
                        selectedMeterTab = .dataGroups
485
                    }
486
                }
487
            )
Bogdan Timofte authored 2 months ago
488
        case .live:
Bogdan Timofte authored 2 months ago
489
            MeterLiveTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 months ago
490
        case .chart:
Bogdan Timofte authored 2 months ago
491
            MeterChartTabView(size: size, isLandscape: false)
Bogdan Timofte authored a month ago
492
        case .chargeRecord:
Bogdan Timofte authored a month ago
493
            MeterChargeRecordTabView().equatable()
Bogdan Timofte authored a month ago
494
        case .dataGroups:
495
            MeterDataGroupsTabView()
Bogdan Timofte authored 2 months ago
496
        case .settings:
Bogdan Timofte authored 2 months ago
497
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
498
                withAnimation(.easeInOut(duration: 0.22)) {
Bogdan Timofte authored 2 months ago
499
                    selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
500
                }
501
            }
Bogdan Timofte authored 2 months ago
502
        }
503
    }
504

            
505
    private var availableMeterTabs: [MeterTab] {
Bogdan Timofte authored a month ago
506
        var tabs: [MeterTab] = [.home, .live, .chart]
507
        if meter.supportsRecordingView {
508
            tabs.append(.chargeRecord)
509
        }
510
        tabs.append(.dataGroups)
511
        tabs.append(.settings)
512
        return tabs
Bogdan Timofte authored 2 months ago
513
    }
514

            
Bogdan Timofte authored 2 months ago
515
    private var displayedMeterTab: MeterTab {
516
        if availableMeterTabs.contains(selectedMeterTab) {
517
            return selectedMeterTab
518
        }
519
        return .home
520
    }
521

            
522
    private func restoreSelectedTab() {
523
        guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else {
524
            meter.preferredTabIdentifier = MeterTab.home.rawValue
525
            selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
526
            return
527
        }
Bogdan Timofte authored 2 months ago
528

            
Bogdan Timofte authored a month ago
529
        selectedMeterTab = availableMeterTabs.contains(restoredTab) ? restoredTab : .home
Bogdan Timofte authored 2 months ago
530
    }
531

            
Bogdan Timofte authored 2 months ago
532
    private var meterBackground: some View {
533
        LinearGradient(
534
            colors: [
535
                meter.color.opacity(0.22),
536
                Color.secondary.opacity(0.08),
537
                Color.clear
538
            ],
539
            startPoint: .topLeading,
540
            endPoint: .bottomTrailing
541
        )
542
        .ignoresSafeArea()
543
    }
544

            
545
    private func isLandscape(size: CGSize) -> Bool {
546
        size.width > size.height
547
    }
548

            
Bogdan Timofte authored 2 months ago
549
    private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle {
Bogdan Timofte authored 2 months ago
550
        if usesOverlayTabBar {
551
            return .landscapeFloating
552
        }
553

            
554
        if landscape {
555
            return .landscapeInline
556
        }
557

            
Bogdan Timofte authored 2 months ago
558
        if Self.isPhone && size.width < 390 {
559
            return .portraitCompact
560
        }
561

            
Bogdan Timofte authored 2 months ago
562
        return .portrait
563
    }
564

            
Bogdan Timofte authored a month ago
565
    private func tabBarPresentation(for size: CGSize, usesOverlayTabBar: Bool) -> AdaptiveTabBarPresentation {
566
        if usesOverlayTabBar {
567
            return AdaptiveTabBarPresentation(
568
                showsTitles: false,
569
                maxWidth: 260
570
            )
571
        }
572

            
573
        return AdaptiveTabBarPresentation.standard(for: size)
574
    }
575

            
Bogdan Timofte authored 2 months ago
576
    private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
577
        if style.floatingInset > 0 {
578
            return max(landscapeTabBarHeight * 0.44, 26)
579
        }
580

            
581
        return max(landscapeTabBarHeight - 6, 0)
582
    }
583

            
Bogdan Timofte authored a month ago
584
    private func title(for tab: MeterTab) -> String {
585
        switch tab {
586
        case .home:
587
            return "Home"
588
        case .live:
589
            return "Live"
590
        case .chart:
591
            return "Chart"
592
        case .chargeRecord:
593
            return "Charge Record"
594
        case .dataGroups:
595
            return meter.dataGroupsTitle
596
        case .settings:
597
            return "Settings"
598
        }
599
    }
600

            
Bogdan Timofte authored a month ago
601
    private func requiresLiveData(_ tab: MeterTab) -> Bool {
602
        switch tab {
603
        case .live, .chart: return true
604
        case .home, .chargeRecord, .dataGroups, .settings: return false
605
        }
606
    }
607

            
608
    private var isLiveDataAvailable: Bool {
609
        meter.operationalState >= .dataIsAvailable
610
    }
611

            
612
    // MARK: - Offline mode
613

            
614
    @ViewBuilder
615
    private func offlineBody(summary: AppData.MeterSummary) -> some View {
616
        VStack(spacing: 0) {
617
            offlineTabBar(tint: summary.tint)
618
            offlineTabContent(summary: summary)
619
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
620
                .id(selectedOfflineTab)
621
                .transition(.opacity.combined(with: .move(edge: .trailing)))
622
                .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab)
623
        }
624
        .background(offlineBackground(tint: summary.tint))
Bogdan Timofte authored a month ago
625
        .navigationTitle(summary.displayName)
626
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
627
        .onAppear {
628
            offlineName = summary.displayName
629
            offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress)
630
        }
631
    }
632

            
633
    private func offlineTabBar(tint: Color) -> some View {
634
        HStack {
635
            Spacer(minLength: 0)
636
            HStack(spacing: 8) {
637
                ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in
638
                    let isSelected = selectedOfflineTab == tab
639
                    Button {
640
                        withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab }
641
                    } label: {
642
                        HStack(spacing: 6) {
643
                            Image(systemName: tab == .info ? "house.fill" : "gearshape.fill")
644
                                .font(.subheadline.weight(.semibold))
645
                            Text(tab == .info ? "Info" : "Settings")
646
                                .font(.subheadline.weight(.semibold))
647
                                .lineLimit(1)
648
                        }
649
                        .foregroundColor(isSelected ? .white : .primary)
650
                        .padding(.horizontal, 10)
651
                        .padding(.vertical, 7)
652
                        .frame(maxWidth: .infinity)
653
                        .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12)))
654
                    }
655
                    .buttonStyle(.plain)
656
                }
657
            }
658
            .padding(6)
659
            .background(
660
                RoundedRectangle(cornerRadius: 14, style: .continuous)
661
                    .fill(Color.secondary.opacity(0.10))
662
            )
663
            .background(
664
                RoundedRectangle(cornerRadius: 14, style: .continuous)
665
                    .fill(.ultraThinMaterial)
666
                    .opacity(0.78)
667
            )
668
            Spacer(minLength: 0)
669
        }
670
        .padding(.horizontal, 16)
671
        .padding(.top, 10)
672
        .padding(.bottom, 8)
673
        .background(
674
            Rectangle()
675
                .fill(.ultraThinMaterial)
676
                .opacity(0.78)
677
                .ignoresSafeArea(edges: .top)
678
        )
679
        .overlay(alignment: .bottom) {
680
            Rectangle()
681
                .fill(Color.secondary.opacity(0.12))
682
                .frame(height: 1)
683
        }
684
    }
685

            
686
    @ViewBuilder
687
    private func offlineTabContent(summary: AppData.MeterSummary) -> some View {
688
        switch selectedOfflineTab {
689
        case .info:
690
            ScrollView {
691
                VStack(alignment: .leading, spacing: 20) {
692
                    offlineStatusHeader(summary: summary)
693
                    MeterInfoCardView(title: "Meter", tint: summary.tint) {
694
                        MeterInfoRowView(label: "Name", value: summary.displayName)
695
                        MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary)
696
                        if let advertisedName = summary.advertisedName {
697
                            MeterInfoRowView(label: "Advertised Name", value: advertisedName)
698
                        }
699
                        MeterInfoRowView(label: "MAC", value: summary.macAddress)
700
                        MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen))
701
                        MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected))
702
                    }
703
                }
704
                .padding(16)
705
            }
706
        case .settings:
707
            offlineSettingsContent(summary: summary)
708
        }
709
    }
710

            
711
    private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View {
712
        let isTC66 = summary.modelSummary == "TC66C"
713
        return ScrollView {
714
            VStack(spacing: 14) {
715
                offlineSettingsCard(title: "Name", tint: summary.tint) {
716
                    HStack {
717
                        Spacer()
718
                        if !offlineEditingName {
719
                            Text(offlineName).foregroundColor(.secondary)
720
                        }
721
                        ChevronView(rotate: $offlineEditingName)
722
                    }
723
                    if offlineEditingName {
724
                        TextField("Name", text: $offlineName, onCommit: {
725
                            appData.setMeterName(offlineName, for: summary.macAddress)
726
                            offlineEditingName = false
727
                        })
728
                        .textFieldStyle(RoundedBorderTextFieldStyle())
729
                        .lineLimit(1)
730
                        .disableAutocorrection(true)
731
                        .multilineTextAlignment(.center)
732
                    }
733
                }
734

            
735
                if isTC66 {
736
                    offlineSettingsCard(
737
                        title: "Meter Temperature Unit",
738
                        infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.",
739
                        tint: .orange
740
                    ) {
741
                        Picker("", selection: $offlineTemperatureUnit) {
742
                            ForEach(TemperatureUnitPreference.allCases) { unit in
743
                                Text(unit.title).tag(unit)
744
                            }
745
                        }
746
                        .pickerStyle(SegmentedPickerStyle())
747
                        .onChange(of: offlineTemperatureUnit) { newValue in
748
                            appData.setTemperatureUnitPreference(newValue, for: summary.macAddress)
749
                        }
750
                    }
751
                }
752

            
753
                offlineSettingsCard(
754
                    title: "Danger Zone",
755
                    infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.",
756
                    tint: .red
757
                ) {
758
                    Button("Delete Meter") {
759
                        offlineDeleteConfirmation = true
760
                    }
761
                    .frame(maxWidth: .infinity)
762
                    .padding(.vertical, 10)
763
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
764
                    .buttonStyle(.plain)
765
                }
766
            }
767
            .padding()
768
        }
769
        .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) {
770
            Button("Delete", role: .destructive) {
771
                appData.deleteMeter(macAddress: summary.macAddress)
772
                dismiss()
773
            }
774
            Button("Cancel", role: .cancel) {}
775
        } message: {
776
            Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
777
        }
778
    }
779

            
780
    private func offlineSettingsCard<Content: View>(
781
        title: String,
782
        infoMessage: String? = nil,
783
        tint: Color,
784
        @ViewBuilder content: () -> Content
785
    ) -> some View {
786
        VStack(alignment: .leading, spacing: 12) {
787
            HStack(spacing: 8) {
788
                Text(title).font(.headline)
789
                if let infoMessage {
790
                    ContextInfoButton(title: title, message: infoMessage)
791
                }
792
            }
793
            content()
794
        }
795
        .padding(18)
796
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
797
    }
798

            
799
    private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
800
        HStack(spacing: 12) {
801
            Image(systemName: "sensor.tag.radiowaves.forward.fill")
802
                .font(.system(size: 22, weight: .semibold))
803
                .foregroundColor(.secondary)
804
            VStack(alignment: .leading, spacing: 4) {
805
                Text(summary.displayName)
806
                    .font(.title3.weight(.semibold))
807
                    .lineLimit(1)
808
                Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary)
809
                    .font(.caption)
810
                    .foregroundColor(.secondary)
811
            }
812
            Spacer()
813
            HStack(spacing: 6) {
814
                Circle().fill(Color.secondary).frame(width: 8, height: 8)
815
                Text("Offline")
816
                    .font(.caption.weight(.semibold))
817
                    .foregroundColor(.secondary)
818
            }
819
            .padding(.horizontal, 10)
820
            .padding(.vertical, 6)
821
            .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12)))
822
            .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1))
823
        }
824
        .padding(14)
825
        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18)
826
    }
827

            
828
    private func offlineBackground(tint: Color) -> some View {
829
        LinearGradient(
830
            colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear],
831
            startPoint: .topLeading,
832
            endPoint: .bottomTrailing
833
        )
834
        .ignoresSafeArea()
835
    }
836

            
837
    private func historyText(for date: Date?) -> String {
838
        guard let date else { return "Never" }
839
        return date.format(as: "yyyy-MM-dd HH:mm")
840
    }
841

            
Bogdan Timofte authored 2 months ago
842
}
843

            
844
private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
845
    static var defaultValue: CGFloat = 0
846

            
847
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
848
        value = max(value, nextValue())
849
    }
Bogdan Timofte authored 2 months ago
850
}
851