Newer Older
995 lines | 36.215kb
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

            
109
    // True only on Mac iPad App (Designed for iPad), false on Catalyst
110
    private static let isTrueMacApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac && !ProcessInfo.processInfo.isMacCatalystApp
Bogdan Timofte authored 2 months ago
111

            
Bogdan Timofte authored 2 months ago
112
    @State private var selectedMeterTab: MeterTab = .home
Bogdan Timofte authored 2 months ago
113
    @State private var navBarTitle: String = "Meter"
114
    @State private var navBarShowRSSI: Bool = false
115
    @State private var navBarRSSI: Int = 0
Bogdan Timofte authored 2 months ago
116
    @State private var landscapeTabBarHeight: CGFloat = 0
Bogdan Timofte authored 2 months ago
117

            
Bogdan Timofte authored a month ago
118
    // Offline mode state
119
    private enum OfflineTab: String { case info, settings }
120
    @State private var selectedOfflineTab: OfflineTab = .info
121
    @State private var offlineEditingName: Bool = false
122
    @State private var offlineName: String = ""
123
    @State private var offlineDeleteConfirmation: Bool = false
124
    @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius
125

            
126
    private let offlineSummary: AppData.MeterSummary?
127

            
128
    init() { offlineSummary = nil }
129
    init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary }
130

            
Bogdan Timofte authored 2 months ago
131
    var body: some View {
Bogdan Timofte authored a month ago
132
        if let summary = offlineSummary {
133
            offlineBody(summary: summary)
134
        } else {
135
            liveBody
136
        }
137
    }
138

            
139
    private var liveBody: some View {
Bogdan Timofte authored 2 months ago
140
        GeometryReader { proxy in
141
            let landscape = isLandscape(size: proxy.size)
Bogdan Timofte authored 2 months ago
142
            let usesOverlayTabBar = landscape && Self.isPhone
Bogdan Timofte authored 2 months ago
143
            let tabBarStyle = tabBarStyle(
144
                for: landscape,
145
                usesOverlayTabBar: usesOverlayTabBar,
146
                size: proxy.size
147
            )
Bogdan Timofte authored a month ago
148
            let tabBarPresentation = tabBarPresentation(
149
                for: proxy.size,
150
                usesOverlayTabBar: usesOverlayTabBar
151
            )
Bogdan Timofte authored 2 months ago
152

            
Bogdan Timofte authored 2 months ago
153
            VStack(spacing: 0) {
Bogdan Timofte authored a month ago
154
                // Use custom header only on true Mac iPad App (Designed for iPad on Mac)
155
                if Self.isTrueMacApp {
Bogdan Timofte authored 2 months ago
156
                    macNavigationHeader
157
                }
158
                Group {
159
                    if landscape {
Bogdan Timofte authored 2 months ago
160
                        landscapeDeck(
161
                            size: proxy.size,
162
                            usesOverlayTabBar: usesOverlayTabBar,
Bogdan Timofte authored a month ago
163
                            tabBarStyle: tabBarStyle,
164
                            tabBarPresentation: tabBarPresentation
Bogdan Timofte authored 2 months ago
165
                        )
Bogdan Timofte authored 2 months ago
166
                    } else {
Bogdan Timofte authored a month ago
167
                        portraitContent(
168
                            size: proxy.size,
169
                            tabBarStyle: tabBarStyle,
170
                            tabBarPresentation: tabBarPresentation
171
                        )
Bogdan Timofte authored 2 months ago
172
                    }
Bogdan Timofte authored 2 months ago
173
                }
Bogdan Timofte authored 2 months ago
174
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 months ago
175
            }
Bogdan Timofte authored 2 months ago
176
            #if !targetEnvironment(macCatalyst)
Bogdan Timofte authored a month ago
177
            .navigationBarHidden(Self.isTrueMacApp && landscape)
Bogdan Timofte authored a month ago
178
            #else
179
            .navigationBarHidden(landscape)
Bogdan Timofte authored 2 months ago
180
            #endif
Bogdan Timofte authored 2 months ago
181
        }
182
        .background(meterBackground)
Bogdan Timofte authored 2 months ago
183
        .modifier(IOSOnlyNavBar(
Bogdan Timofte authored a month ago
184
            apply: !Self.isTrueMacApp,
Bogdan Timofte authored 2 months ago
185
            title: navBarTitle,
186
            showRSSI: navBarShowRSSI,
187
            rssi: navBarRSSI,
188
            meter: meter
189
        ))
190
        .onAppear {
191
            navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
192
            navBarShowRSSI = meter.operationalState > .notPresent
193
            navBarRSSI = meter.btSerial.averageRSSI
194
        }
195
        .onChange(of: meter.name) { name in
196
            navBarTitle = name.isEmpty ? "Meter" : name
197
        }
198
        .onChange(of: meter.operationalState) { state in
199
            navBarShowRSSI = state > .notPresent
200
        }
201
        .onChange(of: meter.btSerial.averageRSSI) { newRSSI in
202
            if abs(newRSSI - navBarRSSI) >= 5 {
203
                navBarRSSI = newRSSI
204
            }
205
        }
Bogdan Timofte authored 2 months ago
206
        .onChange(of: selectedMeterTab) { newTab in
207
            meter.preferredTabIdentifier = newTab.rawValue
208
        }
Bogdan Timofte authored 2 months ago
209
    }
210

            
211
    // MARK: - Custom navigation header for Designed-for-iPad on Mac
212

            
213
    private var macNavigationHeader: some View {
214
        HStack(spacing: 12) {
215
            Button {
216
                dismiss()
217
            } label: {
218
                HStack(spacing: 4) {
219
                    Image(systemName: "chevron.left")
220
                        .font(.body.weight(.semibold))
221
                    Text("USB Meters")
222
                }
223
                .foregroundColor(.accentColor)
224
            }
225
            .buttonStyle(.plain)
226

            
227
            Text(meter.name.isEmpty ? "Meter" : meter.name)
228
                .font(.headline)
229
                .lineLimit(1)
230

            
231
            Spacer()
232

            
Bogdan Timofte authored 2 months ago
233
            MeterConnectionToolbarButton(
234
                operationalState: meter.operationalState,
235
                showsTitle: true,
236
                connectAction: { meter.connect() },
237
                disconnectAction: { meter.disconnect() }
238
            )
239

            
Bogdan Timofte authored 2 months ago
240
            if meter.operationalState > .notPresent {
Bogdan Timofte authored 2 months ago
241
                RSSIView(RSSI: meter.btSerial.averageRSSI)
Bogdan Timofte authored 2 months ago
242
                    .frame(width: 18, height: 18)
243
            }
Bogdan Timofte authored 2 months ago
244

            
245
        }
246
        .padding(.horizontal, 16)
247
        .padding(.vertical, 10)
248
        .background(
249
            Rectangle()
250
                .fill(.ultraThinMaterial)
251
                .ignoresSafeArea(edges: .top)
252
        )
253
        .overlay(alignment: .bottom) {
254
            Rectangle()
255
                .fill(Color.secondary.opacity(0.12))
256
                .frame(height: 1)
257
        }
Bogdan Timofte authored 2 months ago
258
    }
259

            
Bogdan Timofte authored a month ago
260
    private func portraitContent(
261
        size: CGSize,
262
        tabBarStyle: TabBarStyle,
263
        tabBarPresentation: AdaptiveTabBarPresentation
264
    ) -> some View {
265
        portraitSegmentedDeck(
266
            size: size,
267
            tabBarStyle: tabBarStyle,
268
            tabBarPresentation: tabBarPresentation
269
        )
Bogdan Timofte authored 2 months ago
270
    }
271

            
272
    @ViewBuilder
Bogdan Timofte authored a month ago
273
    private func landscapeDeck(
274
        size: CGSize,
275
        usesOverlayTabBar: Bool,
276
        tabBarStyle: TabBarStyle,
277
        tabBarPresentation: AdaptiveTabBarPresentation
278
    ) -> some View {
Bogdan Timofte authored 2 months ago
279
        if usesOverlayTabBar {
Bogdan Timofte authored a month ago
280
            landscapeOverlaySegmentedDeck(
281
                size: size,
282
                tabBarStyle: tabBarStyle,
283
                tabBarPresentation: tabBarPresentation
284
            )
Bogdan Timofte authored 2 months ago
285
        } else {
Bogdan Timofte authored a month ago
286
            landscapeSegmentedDeck(
287
                size: size,
288
                tabBarStyle: tabBarStyle,
289
                tabBarPresentation: tabBarPresentation
290
            )
Bogdan Timofte authored 2 months ago
291
        }
Bogdan Timofte authored 2 months ago
292
    }
293

            
Bogdan Timofte authored a month ago
294
    private func landscapeOverlaySegmentedDeck(
295
        size: CGSize,
296
        tabBarStyle: TabBarStyle,
297
        tabBarPresentation: AdaptiveTabBarPresentation
298
    ) -> some View {
Bogdan Timofte authored 2 months ago
299
        ZStack(alignment: .top) {
300
            landscapeSegmentedContent(size: size)
301
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
302
                .padding(.top, landscapeContentTopPadding(for: tabBarStyle))
Bogdan Timofte authored 2 months ago
303
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
304
                .transition(.opacity.combined(with: .move(edge: .trailing)))
305

            
Bogdan Timofte authored a month ago
306
            segmentedTabBar(
307
                style: tabBarStyle,
308
                presentation: tabBarPresentation,
309
                showsConnectionAction: !Self.isMacIPadApp
310
            )
Bogdan Timofte authored 2 months ago
311
        }
Bogdan Timofte authored 2 months ago
312
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
313
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
314
        .onAppear {
Bogdan Timofte authored 2 months ago
315
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
316
        }
317
        .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
318
            if height > 0 {
319
                landscapeTabBarHeight = height
320
            }
321
        }
Bogdan Timofte authored 2 months ago
322
    }
323

            
Bogdan Timofte authored a month ago
324
    private func landscapeSegmentedDeck(
325
        size: CGSize,
326
        tabBarStyle: TabBarStyle,
327
        tabBarPresentation: AdaptiveTabBarPresentation
328
    ) -> some View {
Bogdan Timofte authored 2 months ago
329
        VStack(spacing: 0) {
Bogdan Timofte authored a month ago
330
            segmentedTabBar(
331
                style: tabBarStyle,
332
                presentation: tabBarPresentation,
333
                showsConnectionAction: !Self.isMacIPadApp
334
            )
Bogdan Timofte authored 2 months ago
335

            
336
            landscapeSegmentedContent(size: size)
337
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
338
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
339
                .transition(.opacity.combined(with: .move(edge: .trailing)))
340
        }
Bogdan Timofte authored 2 months ago
341
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
342
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
343
        .onAppear {
Bogdan Timofte authored 2 months ago
344
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
345
        }
346
    }
347

            
Bogdan Timofte authored a month ago
348
    private func portraitSegmentedDeck(
349
        size: CGSize,
350
        tabBarStyle: TabBarStyle,
351
        tabBarPresentation: AdaptiveTabBarPresentation
352
    ) -> some View {
Bogdan Timofte authored 2 months ago
353
        VStack(spacing: 0) {
Bogdan Timofte authored a month ago
354
            segmentedTabBar(
355
                style: tabBarStyle,
356
                presentation: tabBarPresentation
357
            )
Bogdan Timofte authored 2 months ago
358

            
359
            portraitSegmentedContent(size: size)
360
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
361
                .id(displayedMeterTab)
Bogdan Timofte authored 2 months ago
362
                .transition(.opacity.combined(with: .move(edge: .trailing)))
363
        }
Bogdan Timofte authored 2 months ago
364
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
Bogdan Timofte authored 2 months ago
365
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
366
        .onAppear {
Bogdan Timofte authored 2 months ago
367
            restoreSelectedTab()
Bogdan Timofte authored 2 months ago
368
        }
369
    }
370

            
Bogdan Timofte authored a month ago
371
    private func segmentedTabBar(
372
        style: TabBarStyle,
373
        presentation: AdaptiveTabBarPresentation,
374
        showsConnectionAction: Bool = false
375
    ) -> some View {
Bogdan Timofte authored 2 months ago
376
        let isFloating = style.floatingInset > 0
Bogdan Timofte authored a month ago
377
        let cornerRadius = presentation.showsTitles ? 14.0 : 22.0
Bogdan Timofte authored 2 months ago
378
        let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary
379
        let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12)
Bogdan Timofte authored 2 months ago
380

            
381
        return HStack {
Bogdan Timofte authored 2 months ago
382
            Spacer(minLength: 0)
383

            
384
            HStack(spacing: 8) {
385
                ForEach(availableMeterTabs, id: \.self) { tab in
Bogdan Timofte authored 2 months ago
386
                    let isSelected = displayedMeterTab == tab
Bogdan Timofte authored a month ago
387
                    let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable
Bogdan Timofte authored 2 months ago
388

            
389
                    Button {
390
                        withAnimation(.easeInOut(duration: 0.2)) {
391
                            selectedMeterTab = tab
392
                        }
393
                    } label: {
394
                        HStack(spacing: 6) {
395
                            Image(systemName: tab.systemImage)
396
                                .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
397
                            if presentation.showsTitles {
Bogdan Timofte authored a month ago
398
                                Text(title(for: tab))
Bogdan Timofte authored 2 months ago
399
                                    .font(.subheadline.weight(.semibold))
400
                                    .lineLimit(1)
401
                            }
Bogdan Timofte authored 2 months ago
402
                        }
Bogdan Timofte authored 2 months ago
403
                        .foregroundColor(
404
                            isSelected
405
                            ? .white
Bogdan Timofte authored a month ago
406
                            : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor)
Bogdan Timofte authored 2 months ago
407
                        )
408
                        .padding(.horizontal, style.chipHorizontalPadding)
409
                        .padding(.vertical, style.chipVerticalPadding)
Bogdan Timofte authored 2 months ago
410
                        .frame(maxWidth: .infinity)
411
                        .background(
412
                            Capsule()
Bogdan Timofte authored 2 months ago
413
                                .fill(
414
                                    isSelected
415
                                    ? meter.color.opacity(isFloating ? 0.94 : 1)
Bogdan Timofte authored a month ago
416
                                    : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill)
Bogdan Timofte authored 2 months ago
417
                                )
Bogdan Timofte authored 2 months ago
418
                        )
Bogdan Timofte authored 2 months ago
419
                    }
Bogdan Timofte authored 2 months ago
420
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
421
                    .accessibilityLabel(title(for: tab))
Bogdan Timofte authored 2 months ago
422
                }
423
            }
Bogdan Timofte authored a month ago
424
            .frame(maxWidth: presentation.maxWidth)
Bogdan Timofte authored 2 months ago
425
            .padding(style.outerPadding)
Bogdan Timofte authored 2 months ago
426
            .background(
Bogdan Timofte authored 2 months ago
427
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
428
                    .fill(
429
                        isFloating
430
                        ? LinearGradient(
431
                            colors: [
Bogdan Timofte authored 2 months ago
432
                                Color.white.opacity(0.76),
433
                                Color.white.opacity(0.52)
Bogdan Timofte authored 2 months ago
434
                            ],
435
                            startPoint: .topLeading,
436
                            endPoint: .bottomTrailing
437
                        )
438
                        : LinearGradient(
439
                            colors: [
440
                                Color.secondary.opacity(style.barBackgroundOpacity),
441
                                Color.secondary.opacity(style.barBackgroundOpacity)
442
                            ],
443
                            startPoint: .topLeading,
444
                            endPoint: .bottomTrailing
445
                        )
446
                    )
Bogdan Timofte authored 2 months ago
447
            )
Bogdan Timofte authored 2 months ago
448
            .overlay {
449
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
450
                    .stroke(
Bogdan Timofte authored 2 months ago
451
                        isFloating ? Color.black.opacity(0.08) : Color.clear,
Bogdan Timofte authored 2 months ago
452
                        lineWidth: 1
453
                    )
454
            }
455
            .background {
Bogdan Timofte authored 2 months ago
456
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
457
                    .fill(.ultraThinMaterial)
458
                    .opacity(style.materialOpacity)
Bogdan Timofte authored 2 months ago
459
            }
460
            .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12)
Bogdan Timofte authored 2 months ago
461

            
462
            Spacer(minLength: 0)
463
        }
Bogdan Timofte authored 2 months ago
464
        .padding(.horizontal, style.horizontalPadding)
465
        .padding(.top, style.topPadding)
466
        .padding(.bottom, style.bottomPadding)
Bogdan Timofte authored 2 months ago
467
        .background(
Bogdan Timofte authored 2 months ago
468
            GeometryReader { geometry in
469
                Color.clear
470
                    .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height)
471
            }
Bogdan Timofte authored 2 months ago
472
        )
Bogdan Timofte authored 2 months ago
473
        .padding(.horizontal, style.floatingInset)
474
        .background {
475
            if style.floatingInset == 0 {
476
                Rectangle()
477
                    .fill(.ultraThinMaterial)
478
                    .opacity(style.materialOpacity)
479
                    .ignoresSafeArea(edges: .top)
480
            }
481
        }
Bogdan Timofte authored 2 months ago
482
        .overlay(alignment: .bottom) {
Bogdan Timofte authored 2 months ago
483
            if style.floatingInset == 0 {
484
                Rectangle()
485
                    .fill(Color.secondary.opacity(0.12))
486
                    .frame(height: 1)
487
            }
488
        }
489
        .overlay(alignment: .trailing) {
490
            if showsConnectionAction {
491
                MeterConnectionToolbarButton(
492
                    operationalState: meter.operationalState,
493
                    showsTitle: false,
494
                    connectAction: { meter.connect() },
495
                    disconnectAction: { meter.disconnect() }
496
                )
497
                .font(.title3.weight(.semibold))
498
                .padding(.trailing, style.horizontalPadding + style.floatingInset + 4)
499
                .padding(.top, style.topPadding)
500
                .padding(.bottom, style.bottomPadding)
501
            }
Bogdan Timofte authored 2 months ago
502
        }
Bogdan Timofte authored 2 months ago
503
    }
504

            
Bogdan Timofte authored 2 months ago
505
    @ViewBuilder
506
    private func landscapeSegmentedContent(size: CGSize) -> some View {
Bogdan Timofte authored 2 months ago
507
        switch displayedMeterTab {
Bogdan Timofte authored 2 months ago
508
        case .home:
Bogdan Timofte authored a month ago
509
            MeterHomeTabView(
510
                size: size,
511
                isLandscape: true,
512
                showChargeRecordTab: {
513
                    withAnimation(.easeInOut(duration: 0.22)) {
514
                        selectedMeterTab = .chargeRecord
515
                    }
516
                },
517
                showDataGroupsTab: {
518
                    withAnimation(.easeInOut(duration: 0.22)) {
519
                        selectedMeterTab = .dataGroups
520
                    }
521
                }
522
            )
Bogdan Timofte authored 2 months ago
523
        case .live:
Bogdan Timofte authored 2 months ago
524
            MeterLiveTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 months ago
525
        case .chart:
Bogdan Timofte authored 2 months ago
526
            MeterChartTabView(size: size, isLandscape: true)
Bogdan Timofte authored a month ago
527
        case .chargeRecord:
Bogdan Timofte authored a month ago
528
            MeterChargeRecordTabView().equatable()
Bogdan Timofte authored a month ago
529
        case .dataGroups:
530
            MeterDataGroupsTabView()
Bogdan Timofte authored 2 months ago
531
        case .settings:
Bogdan Timofte authored 2 months ago
532
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
533
                withAnimation(.easeInOut(duration: 0.22)) {
Bogdan Timofte authored 2 months ago
534
                    selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
535
                }
536
            }
Bogdan Timofte authored 2 months ago
537
        }
538
    }
Bogdan Timofte authored 2 months ago
539

            
Bogdan Timofte authored 2 months ago
540
    @ViewBuilder
541
    private func portraitSegmentedContent(size: CGSize) -> some View {
Bogdan Timofte authored 2 months ago
542
        switch displayedMeterTab {
Bogdan Timofte authored 2 months ago
543
        case .home:
Bogdan Timofte authored a month ago
544
            MeterHomeTabView(
545
                size: size,
546
                isLandscape: false,
547
                showChargeRecordTab: {
548
                    withAnimation(.easeInOut(duration: 0.22)) {
549
                        selectedMeterTab = .chargeRecord
550
                    }
551
                },
552
                showDataGroupsTab: {
553
                    withAnimation(.easeInOut(duration: 0.22)) {
554
                        selectedMeterTab = .dataGroups
555
                    }
556
                }
557
            )
Bogdan Timofte authored 2 months ago
558
        case .live:
Bogdan Timofte authored 2 months ago
559
            MeterLiveTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 months ago
560
        case .chart:
Bogdan Timofte authored 2 months ago
561
            MeterChartTabView(size: size, isLandscape: false)
Bogdan Timofte authored a month ago
562
        case .chargeRecord:
Bogdan Timofte authored a month ago
563
            MeterChargeRecordTabView().equatable()
Bogdan Timofte authored a month ago
564
        case .dataGroups:
565
            MeterDataGroupsTabView()
Bogdan Timofte authored 2 months ago
566
        case .settings:
Bogdan Timofte authored 2 months ago
567
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
568
                withAnimation(.easeInOut(duration: 0.22)) {
Bogdan Timofte authored 2 months ago
569
                    selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
570
                }
571
            }
Bogdan Timofte authored 2 months ago
572
        }
573
    }
574

            
575
    private var availableMeterTabs: [MeterTab] {
Bogdan Timofte authored a month ago
576
        var tabs: [MeterTab] = [.home, .live, .chart]
577
        if meter.supportsRecordingView {
578
            tabs.append(.chargeRecord)
579
        }
580
        tabs.append(.dataGroups)
581
        tabs.append(.settings)
582
        return tabs
Bogdan Timofte authored 2 months ago
583
    }
584

            
Bogdan Timofte authored 2 months ago
585
    private var displayedMeterTab: MeterTab {
586
        if availableMeterTabs.contains(selectedMeterTab) {
587
            return selectedMeterTab
588
        }
589
        return .home
590
    }
591

            
592
    private func restoreSelectedTab() {
593
        guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else {
594
            meter.preferredTabIdentifier = MeterTab.home.rawValue
595
            selectedMeterTab = .home
Bogdan Timofte authored 2 months ago
596
            return
597
        }
Bogdan Timofte authored 2 months ago
598

            
Bogdan Timofte authored a month ago
599
        selectedMeterTab = availableMeterTabs.contains(restoredTab) ? restoredTab : .home
Bogdan Timofte authored 2 months ago
600
    }
601

            
Bogdan Timofte authored 2 months ago
602
    private var meterBackground: some View {
603
        LinearGradient(
604
            colors: [
605
                meter.color.opacity(0.22),
606
                Color.secondary.opacity(0.08),
607
                Color.clear
608
            ],
609
            startPoint: .topLeading,
610
            endPoint: .bottomTrailing
611
        )
612
        .ignoresSafeArea()
613
    }
614

            
615
    private func isLandscape(size: CGSize) -> Bool {
616
        size.width > size.height
617
    }
618

            
Bogdan Timofte authored 2 months ago
619
    private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle {
Bogdan Timofte authored 2 months ago
620
        if usesOverlayTabBar {
621
            return .landscapeFloating
622
        }
623

            
624
        if landscape {
625
            return .landscapeInline
626
        }
627

            
Bogdan Timofte authored 2 months ago
628
        if Self.isPhone && size.width < 390 {
629
            return .portraitCompact
630
        }
631

            
Bogdan Timofte authored 2 months ago
632
        return .portrait
633
    }
634

            
Bogdan Timofte authored a month ago
635
    private func tabBarPresentation(for size: CGSize, usesOverlayTabBar: Bool) -> AdaptiveTabBarPresentation {
636
        if usesOverlayTabBar {
637
            return AdaptiveTabBarPresentation(
638
                showsTitles: false,
639
                maxWidth: 260
640
            )
641
        }
642

            
643
        return AdaptiveTabBarPresentation.standard(for: size)
644
    }
645

            
Bogdan Timofte authored 2 months ago
646
    private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
647
        if style.floatingInset > 0 {
648
            return max(landscapeTabBarHeight * 0.44, 26)
649
        }
650

            
651
        return max(landscapeTabBarHeight - 6, 0)
652
    }
653

            
Bogdan Timofte authored a month ago
654
    private func title(for tab: MeterTab) -> String {
655
        switch tab {
656
        case .home:
657
            return "Home"
658
        case .live:
659
            return "Live"
660
        case .chart:
661
            return "Chart"
662
        case .chargeRecord:
663
            return "Charge Record"
664
        case .dataGroups:
665
            return meter.dataGroupsTitle
666
        case .settings:
667
            return "Settings"
668
        }
669
    }
670

            
Bogdan Timofte authored a month ago
671
    private func requiresLiveData(_ tab: MeterTab) -> Bool {
672
        switch tab {
673
        case .live, .chart: return true
674
        case .home, .chargeRecord, .dataGroups, .settings: return false
675
        }
676
    }
677

            
678
    private var isLiveDataAvailable: Bool {
679
        meter.operationalState >= .dataIsAvailable
680
    }
681

            
682
    // MARK: - Offline mode
683

            
684
    @ViewBuilder
685
    private func offlineBody(summary: AppData.MeterSummary) -> some View {
686
        VStack(spacing: 0) {
687
            if Self.isTrueMacApp {
688
                offlineMacHeader(name: summary.displayName)
689
            }
690
            offlineTabBar(tint: summary.tint)
691
            offlineTabContent(summary: summary)
692
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
693
                .id(selectedOfflineTab)
694
                .transition(.opacity.combined(with: .move(edge: .trailing)))
695
                .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab)
696
        }
697
        .background(offlineBackground(tint: summary.tint))
698
        #if !targetEnvironment(macCatalyst)
699
        .navigationBarHidden(Self.isTrueMacApp)
700
        #else
701
        .navigationBarHidden(false)
702
        #endif
703
        .navigationBarTitle(summary.displayName, displayMode: .inline)
704
        .onAppear {
705
            offlineName = summary.displayName
706
            offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress)
707
        }
708
    }
709

            
710
    private func offlineTabBar(tint: Color) -> some View {
711
        HStack {
712
            Spacer(minLength: 0)
713
            HStack(spacing: 8) {
714
                ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in
715
                    let isSelected = selectedOfflineTab == tab
716
                    Button {
717
                        withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab }
718
                    } label: {
719
                        HStack(spacing: 6) {
720
                            Image(systemName: tab == .info ? "house.fill" : "gearshape.fill")
721
                                .font(.subheadline.weight(.semibold))
722
                            Text(tab == .info ? "Info" : "Settings")
723
                                .font(.subheadline.weight(.semibold))
724
                                .lineLimit(1)
725
                        }
726
                        .foregroundColor(isSelected ? .white : .primary)
727
                        .padding(.horizontal, 10)
728
                        .padding(.vertical, 7)
729
                        .frame(maxWidth: .infinity)
730
                        .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12)))
731
                    }
732
                    .buttonStyle(.plain)
733
                }
734
            }
735
            .padding(6)
736
            .background(
737
                RoundedRectangle(cornerRadius: 14, style: .continuous)
738
                    .fill(Color.secondary.opacity(0.10))
739
            )
740
            .background(
741
                RoundedRectangle(cornerRadius: 14, style: .continuous)
742
                    .fill(.ultraThinMaterial)
743
                    .opacity(0.78)
744
            )
745
            Spacer(minLength: 0)
746
        }
747
        .padding(.horizontal, 16)
748
        .padding(.top, 10)
749
        .padding(.bottom, 8)
750
        .background(
751
            Rectangle()
752
                .fill(.ultraThinMaterial)
753
                .opacity(0.78)
754
                .ignoresSafeArea(edges: .top)
755
        )
756
        .overlay(alignment: .bottom) {
757
            Rectangle()
758
                .fill(Color.secondary.opacity(0.12))
759
                .frame(height: 1)
760
        }
761
    }
762

            
763
    @ViewBuilder
764
    private func offlineTabContent(summary: AppData.MeterSummary) -> some View {
765
        switch selectedOfflineTab {
766
        case .info:
767
            ScrollView {
768
                VStack(alignment: .leading, spacing: 20) {
769
                    offlineStatusHeader(summary: summary)
770
                    MeterInfoCardView(title: "Meter", tint: summary.tint) {
771
                        MeterInfoRowView(label: "Name", value: summary.displayName)
772
                        MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary)
773
                        if let advertisedName = summary.advertisedName {
774
                            MeterInfoRowView(label: "Advertised Name", value: advertisedName)
775
                        }
776
                        MeterInfoRowView(label: "MAC", value: summary.macAddress)
777
                        MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen))
778
                        MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected))
779
                    }
780
                }
781
                .padding(16)
782
            }
783
        case .settings:
784
            offlineSettingsContent(summary: summary)
785
        }
786
    }
787

            
788
    private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View {
789
        let isTC66 = summary.modelSummary == "TC66C"
790
        return ScrollView {
791
            VStack(spacing: 14) {
792
                offlineSettingsCard(title: "Name", tint: summary.tint) {
793
                    HStack {
794
                        Spacer()
795
                        if !offlineEditingName {
796
                            Text(offlineName).foregroundColor(.secondary)
797
                        }
798
                        ChevronView(rotate: $offlineEditingName)
799
                    }
800
                    if offlineEditingName {
801
                        TextField("Name", text: $offlineName, onCommit: {
802
                            appData.setMeterName(offlineName, for: summary.macAddress)
803
                            offlineEditingName = false
804
                        })
805
                        .textFieldStyle(RoundedBorderTextFieldStyle())
806
                        .lineLimit(1)
807
                        .disableAutocorrection(true)
808
                        .multilineTextAlignment(.center)
809
                    }
810
                }
811

            
812
                if isTC66 {
813
                    offlineSettingsCard(
814
                        title: "Meter Temperature Unit",
815
                        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.",
816
                        tint: .orange
817
                    ) {
818
                        Picker("", selection: $offlineTemperatureUnit) {
819
                            ForEach(TemperatureUnitPreference.allCases) { unit in
820
                                Text(unit.title).tag(unit)
821
                            }
822
                        }
823
                        .pickerStyle(SegmentedPickerStyle())
824
                        .onChange(of: offlineTemperatureUnit) { newValue in
825
                            appData.setTemperatureUnitPreference(newValue, for: summary.macAddress)
826
                        }
827
                    }
828
                }
829

            
830
                offlineSettingsCard(
831
                    title: "Danger Zone",
832
                    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.",
833
                    tint: .red
834
                ) {
835
                    Button("Delete Meter") {
836
                        offlineDeleteConfirmation = true
837
                    }
838
                    .frame(maxWidth: .infinity)
839
                    .padding(.vertical, 10)
840
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
841
                    .buttonStyle(.plain)
842
                }
843
            }
844
            .padding()
845
        }
846
        .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) {
847
            Button("Delete", role: .destructive) {
848
                appData.deleteMeter(macAddress: summary.macAddress)
849
                dismiss()
850
            }
851
            Button("Cancel", role: .cancel) {}
852
        } message: {
853
            Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
854
        }
855
    }
856

            
857
    private func offlineSettingsCard<Content: View>(
858
        title: String,
859
        infoMessage: String? = nil,
860
        tint: Color,
861
        @ViewBuilder content: () -> Content
862
    ) -> some View {
863
        VStack(alignment: .leading, spacing: 12) {
864
            HStack(spacing: 8) {
865
                Text(title).font(.headline)
866
                if let infoMessage {
867
                    ContextInfoButton(title: title, message: infoMessage)
868
                }
869
            }
870
            content()
871
        }
872
        .padding(18)
873
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
874
    }
875

            
876
    private func offlineMacHeader(name: String) -> some View {
877
        HStack(spacing: 12) {
878
            Button { dismiss() } label: {
879
                HStack(spacing: 4) {
880
                    Image(systemName: "chevron.left")
881
                        .font(.body.weight(.semibold))
882
                    Text("USB Meters")
883
                }
884
                .foregroundColor(.accentColor)
885
            }
886
            .buttonStyle(.plain)
887
            Text(name).font(.headline).lineLimit(1)
888
            Spacer()
889
        }
890
        .padding(.horizontal, 16)
891
        .padding(.vertical, 10)
892
        .background(
893
            Rectangle()
894
                .fill(.ultraThinMaterial)
895
                .ignoresSafeArea(edges: .top)
896
        )
897
        .overlay(alignment: .bottom) {
898
            Rectangle()
899
                .fill(Color.secondary.opacity(0.12))
900
                .frame(height: 1)
901
        }
902
    }
903

            
904
    private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
905
        HStack(spacing: 12) {
906
            Image(systemName: "sensor.tag.radiowaves.forward.fill")
907
                .font(.system(size: 22, weight: .semibold))
908
                .foregroundColor(.secondary)
909
            VStack(alignment: .leading, spacing: 4) {
910
                Text(summary.displayName)
911
                    .font(.title3.weight(.semibold))
912
                    .lineLimit(1)
913
                Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary)
914
                    .font(.caption)
915
                    .foregroundColor(.secondary)
916
            }
917
            Spacer()
918
            HStack(spacing: 6) {
919
                Circle().fill(Color.secondary).frame(width: 8, height: 8)
920
                Text("Offline")
921
                    .font(.caption.weight(.semibold))
922
                    .foregroundColor(.secondary)
923
            }
924
            .padding(.horizontal, 10)
925
            .padding(.vertical, 6)
926
            .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12)))
927
            .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1))
928
        }
929
        .padding(14)
930
        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18)
931
    }
932

            
933
    private func offlineBackground(tint: Color) -> some View {
934
        LinearGradient(
935
            colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear],
936
            startPoint: .topLeading,
937
            endPoint: .bottomTrailing
938
        )
939
        .ignoresSafeArea()
940
    }
941

            
942
    private func historyText(for date: Date?) -> String {
943
        guard let date else { return "Never" }
944
        return date.format(as: "yyyy-MM-dd HH:mm")
945
    }
946

            
Bogdan Timofte authored 2 months ago
947
}
948

            
949
private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
950
    static var defaultValue: CGFloat = 0
951

            
952
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
953
        value = max(value, nextValue())
954
    }
Bogdan Timofte authored 2 months ago
955
}
956

            
Bogdan Timofte authored 2 months ago
957
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
958

            
959
private struct IOSOnlyNavBar: ViewModifier {
960
    let apply: Bool
961
    let title: String
962
    let showRSSI: Bool
963
    let rssi: Int
964
    let meter: Meter
965

            
966
    @ViewBuilder
967
    func body(content: Content) -> some View {
968
        if apply {
969
            content
Bogdan Timofte authored a month ago
970
                .navigationBarTitle(title, displayMode: .inline)
Bogdan Timofte authored 2 months ago
971
                .toolbar {
972
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
Bogdan Timofte authored 2 months ago
973
                        MeterConnectionToolbarButton(
974
                            operationalState: meter.operationalState,
975
                            showsTitle: false,
976
                            connectAction: { meter.connect() },
977
                            disconnectAction: { meter.disconnect() }
978
                        )
979
                        .font(.body.weight(.semibold))
Bogdan Timofte authored 2 months ago
980
                        if showRSSI {
981
                            RSSIView(RSSI: rssi)
982
                                .frame(width: 18, height: 18)
983
                        }
984
                    }
985
                }
Bogdan Timofte authored a month ago
986
                #if targetEnvironment(macCatalyst)
987
                .toolbar {
988
                    ToolbarItemGroup(placement: .primaryAction) {}
989
                }
990
                #endif
Bogdan Timofte authored 2 months ago
991
        } else {
992
            content
993
        }
994
    }
995
}