Compare changes
Compare changes across branches, commits, tags, and more below. If you need to, you can also compare across forks.

...
Commits not after 2026-04-08
Showing 64 changed files with 7440 additions and 2274 deletions
+106 -0
Documentation/Project Structure and Naming.md
@@ -0,0 +1,106 @@
1
+# Project Structure and Naming
2
+
3
+This document defines how we name SwiftUI views and how we place files in the project so the codebase matches the product language and the UI hierarchy.
4
+
5
+## Core Rules
6
+
7
+- Name folders after the feature or navigation surface the user sees.
8
+  - Example: the Home tab lives in `Views/Meter/Tabs/Home/`, not in `Connection/`.
9
+- Name the main view in a folder after the feature it owns.
10
+  - Example: `MeterHomeTabView.swift` is the root view for the meter Home tab.
11
+- Keep the file name and the Swift type name identical.
12
+  - Example: `MeterOverviewSectionView.swift` contains `MeterOverviewSectionView`.
13
+- Prefer product language over implementation history.
14
+  - If the app says "Home", use `Home` in code instead of older terms like `Connection`.
15
+- Use `Components/` only for reusable building blocks.
16
+  - A component is used in multiple views or is clearly meant to be reused.
17
+  - Example: `MeterInfoCardView` and `MeterInfoRowView`.
18
+- Use `Subviews/` for views that belong to a single parent feature.
19
+  - A subview is used once or is tightly coupled to one screen/tab.
20
+  - Example: `Views/Meter/Tabs/Home/Subviews/MeterOverviewSectionView.swift`.
21
+- Use `Sheets/` for modal flows presented from another screen.
22
+  - Example: `Views/Meter/Sheets/AppHistory/AppHistorySheetView.swift`.
23
+- Keep reusable components close to the narrowest shared owner.
24
+  - If a component is reused only inside Meter screens, keep it in `Views/Meter/Components/`.
25
+  - Do not move it to app-wide `Views/Components/` unless it is truly generic.
26
+- Keep small support types next to their owner.
27
+  - If a helper type exists for one view only, keep it in the same file or the same feature subfolder.
28
+- Avoid vague verbs or placeholder names.
29
+  - Prefer `MeterNameEditorView` over `EditNameView`.
30
+  - Prefer `MeterConnectionActionView` over `ConnectionPrimaryActionView`.
31
+- A folder should describe ownership, not implementation detail.
32
+  - `Home/Subviews/` is better than `Connection/Components/` when the views are single-use parts of the Home tab.
33
+
34
+## Naming Checklist
35
+
36
+Before adding or renaming a file, check:
37
+
38
+- Can someone guess the file location from the screen name?
39
+- Does the type name say what the view renders?
40
+- Is this reused enough to deserve `Components/`?
41
+- If it is single-use, does it live under the parent feature's `Subviews/` folder?
42
+- Does the code use the same words as the UI?
43
+
44
+## Current Meter Tab Pattern
45
+
46
+Use this structure for Meter tab work:
47
+
48
+```text
49
+Views/Meter/
50
+  Components/
51
+    MeasurementChartView.swift
52
+    MeterInfoCardView.swift
53
+    MeterInfoRowView.swift
54
+  Sheets/
55
+    AppHistory/
56
+      AppHistorySheetView.swift
57
+      Subviews/
58
+        AppHistorySampleView.swift
59
+    ChargeRecord/
60
+      ChargeRecordSheetView.swift
61
+      Subviews/
62
+        ChargeRecordMetricsTableView.swift
63
+    DataGroups/
64
+      DataGroupsSheetView.swift
65
+      Subviews/
66
+        DataGroupRowView.swift
67
+  Tabs/
68
+    Home/
69
+      MeterHomeTabView.swift
70
+      Subviews/
71
+        MeterConnectionActionView.swift
72
+        MeterConnectionStatusBadgeView.swift
73
+        MeterOverviewSectionView.swift
74
+    Live/
75
+      MeterLiveTabView.swift
76
+      Subviews/
77
+        LoadResistanceIconView.swift
78
+        MeterLiveContentView.swift
79
+        MeterLiveMetricRange.swift
80
+    Chart/
81
+      MeterChartTabView.swift
82
+    Settings/
83
+      MeterSettingsTabView.swift
84
+      Subviews/
85
+        MeterCurrentScreenSummaryView.swift
86
+        MeterNameEditorView.swift
87
+        MeterScreenControlButtonView.swift
88
+        MeterScreenControlsView.swift
89
+        ScreenBrightnessEditorView.swift
90
+        ScreenTimeoutEditorView.swift
91
+```
92
+
93
+## Refactor Examples
94
+
95
+- `Connection/` -> `Home/`
96
+- `MeterConnectionTabView` -> `MeterHomeTabView`
97
+- `ConnectionHomeInfoPreviewView` -> `MeterOverviewSectionView`
98
+- `ConnectionPrimaryActionView` -> `MeterConnectionActionView`
99
+- `EditNameView` -> `MeterNameEditorView`
100
+- `MeasurementsView` -> `AppHistorySheetView`
101
+- `RecordingView` -> `ChargeRecordSheetView`
102
+- `ControlView` -> `MeterScreenControlsView`
103
+
104
+## Decision Rule
105
+
106
+If a new name makes a teammate look in the right folder on the first try, it is probably a good name.
+2 -0
Documentation/README.md
@@ -10,6 +10,8 @@ It is intended to keep the repository root focused on the app itself while prese
10 10
   Narrative context and decisions that explain how the project got here.
11 11
 - `Platform Decision - iOS 15.md`
12 12
   App-level platform choices that affect implementation.
13
+- `Project Structure and Naming.md`
14
+  Naming and file-organization rules for views, features, components, and subviews.
13 15
 - `Research Resources/`
14 16
   External source material plus the notes derived from it.
15 17
 
+269 -67
USB Meter.xcodeproj/project.pbxproj
@@ -3,28 +3,27 @@
3 3
 	archiveVersion = 1;
4 4
 	classes = {
5 5
 	};
6
-	objectVersion = 54;
6
+	objectVersion = 70;
7 7
 	objects = {
8 8
 
9 9
 /* Begin PBXBuildFile section */
10
+		3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */; };
10 11
 		4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF8524176CAB0002E80B /* DataGroupRowView.swift */; };
11
-		4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsView.swift */; };
12
+		4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */; };
12 13
 		430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
13 14
 		4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
14 15
 		4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4327461A24619CED0009BE4B /* MeterRowView.swift */; };
15 16
 		432EA6442445A559006FC905 /* ChartContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432EA6432445A559006FC905 /* ChartContext.swift */; };
16 17
 		4347F01D28D717C1007EE7B1 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4347F01C28D717C1007EE7B1 /* CryptoSwift */; };
17 18
 		4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4351E7BA24685ACD00E798A3 /* CGPoint.swift */; };
18
-		43554B2F24443939004E66F5 /* MeasurementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementsView.swift */; };
19
-		43554B32244449B5004E66F5 /* MeasurementPointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementPointView.swift */; };
19
+		43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */; };
20
+		43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */; };
20 21
 		43554B3424444B0E004E66F5 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B3324444B0E004E66F5 /* Date.swift */; };
21
-		43567FE92443AD7C00000282 /* ICloudDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43567FE82443AD7C00000282 /* ICloudDefault.swift */; };
22 22
 		4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34C241CBB3800B464F9 /* RSSIView.swift */; };
23
-		4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */; };
24
-		437D47D12415F91B00B7768E /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* LiveView.swift */; };
23
+		437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* MeterLiveContentView.swift */; };
25 24
 		437D47D32415FB7E00B7768E /* Decimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D22415FB7E00B7768E /* Decimal.swift */; };
26
-		437D47D52415FD8C00B7768E /* RecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D42415FD8C00B7768E /* RecordingView.swift */; };
27
-		437D47D72415FDF300B7768E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D62415FDF300B7768E /* ControlView.swift */; };
25
+		437D47D52415FD8C00B7768E /* ChargeRecordSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */; };
26
+		437D47D72415FDF300B7768E /* MeterScreenControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */; };
28 27
 		437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F0AB62463108F005DEBEC /* MeasurementChartView.swift */; };
29 28
 		4383B460240EB2D000DAAEBF /* Meter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B45F240EB2D000DAAEBF /* Meter.swift */; };
30 29
 		4383B462240EB5E400DAAEBF /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B461240EB5E400DAAEBF /* AppData.swift */; };
@@ -42,16 +41,34 @@
42 41
 		439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439D996424234B98008DE3AA /* BluetoothRadio.swift */; };
43 42
 		43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF65F240BF3EB00255B8B /* AppDelegate.swift */; };
44 43
 		43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF661240BF3EB00255B8B /* SceneDelegate.swift */; };
45
-		43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */; };
46 44
 		43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF666240BF3EB00255B8B /* ContentView.swift */; };
47 45
 		43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF668240BF3ED00255B8B /* Assets.xcassets */; };
48 46
 		43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */; };
49 47
 		43CBF66F240BF3ED00255B8B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF66D240BF3ED00255B8B /* LaunchScreen.storyboard */; };
50 48
 		43CBF677240C043E00255B8B /* BluetoothManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF676240C043E00255B8B /* BluetoothManager.swift */; };
51 49
 		43CBF681240D153000255B8B /* CBManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF680240D153000255B8B /* CBManagerState.swift */; };
52
-		43DFBE402441A37B004A47EA /* BorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DFBE3F2441A37B004A47EA /* BorderView.swift */; };
53 50
 		43ED78AE2420A0BE00974487 /* BluetoothSerial.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */; };
54 51
 		43F7792B2465AE1600745DF4 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F7792A2465AE1600745DF4 /* UIView.swift */; };
52
+		AAD5F9A72B1CAC0700F8E4F9 /* MeterDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */; };
53
+		AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */; };
54
+		D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
55
+		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
56
+		D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
57
+		D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */; };
58
+		D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */; };
59
+		D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */; };
60
+		D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */; };
61
+		D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */; };
62
+		D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */; };
63
+		D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */; };
64
+		D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */; };
65
+		D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */; };
66
+		D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */; };
67
+		D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */; };
68
+		D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */; };
69
+		D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */; };
70
+		D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */; };
71
+		E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
55 72
 /* End PBXBuildFile section */
56 73
 
57 74
 /* Begin PBXFileReference section */
@@ -87,22 +104,20 @@
87 104
 		1C6B6BB32A2D4F5100A0B001 /* Users-Manual-4216091.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "Users-Manual-4216091.pdf"; sourceTree = "<group>"; };
88 105
 		1C6B6BB42A2D4F5100A0B001 /* HM-10 and DX-BT18 Module Working Summary.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "HM-10 and DX-BT18 Module Working Summary.md"; sourceTree = "<group>"; };
89 106
 		4308CF8524176CAB0002E80B /* DataGroupRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupRowView.swift; sourceTree = "<group>"; };
90
-		4308CF872417770D0002E80B /* DataGroupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsView.swift; sourceTree = "<group>"; };
107
+		4308CF872417770D0002E80B /* DataGroupsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsSheetView.swift; sourceTree = "<group>"; };
91 108
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
92 109
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
93 110
 		4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
94 111
 		432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
95 112
 		4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
96
-		43554B2E24443939004E66F5 /* MeasurementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementsView.swift; sourceTree = "<group>"; };
97
-		43554B31244449B5004E66F5 /* MeasurementPointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementPointView.swift; sourceTree = "<group>"; };
113
+		43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSheetView.swift; sourceTree = "<group>"; };
114
+		43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSampleView.swift; sourceTree = "<group>"; };
98 115
 		43554B3324444B0E004E66F5 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
99
-		43567FE82443AD7C00000282 /* ICloudDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudDefault.swift; sourceTree = "<group>"; };
100 116
 		4360A34C241CBB3800B464F9 /* RSSIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSIView.swift; sourceTree = "<group>"; };
101
-		4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsView.swift; sourceTree = "<group>"; };
102
-		437D47D02415F91B00B7768E /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
117
+		437D47D02415F91B00B7768E /* MeterLiveContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveContentView.swift; sourceTree = "<group>"; };
103 118
 		437D47D22415FB7E00B7768E /* Decimal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Decimal.swift; sourceTree = "<group>"; };
104
-		437D47D42415FD8C00B7768E /* RecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingView.swift; sourceTree = "<group>"; };
105
-		437D47D62415FDF300B7768E /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = "<group>"; };
119
+		437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordSheetView.swift; sourceTree = "<group>"; };
120
+		437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlsView.swift; sourceTree = "<group>"; };
106 121
 		437F0AB62463108F005DEBEC /* MeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementChartView.swift; sourceTree = "<group>"; };
107 122
 		4383B45F240EB2D000DAAEBF /* Meter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meter.swift; sourceTree = "<group>"; };
108 123
 		4383B461240EB5E400DAAEBF /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
@@ -121,7 +136,6 @@
121 136
 		43CBF65C240BF3EB00255B8B /* USB Meter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "USB Meter.app"; sourceTree = BUILT_PRODUCTS_DIR; };
122 137
 		43CBF65F240BF3EB00255B8B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
123 138
 		43CBF661240BF3EB00255B8B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
124
-		43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = USB_Meter.xcdatamodel; sourceTree = "<group>"; };
125 139
 		43CBF666240BF3EB00255B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
126 140
 		43CBF668240BF3ED00255B8B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
127 141
 		43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
@@ -130,11 +144,35 @@
130 144
 		43CBF676240C043E00255B8B /* BluetoothManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManager.swift; sourceTree = "<group>"; };
131 145
 		43CBF67A240C0D8A00255B8B /* USB Meter.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "USB Meter.entitlements"; sourceTree = "<group>"; };
132 146
 		43CBF680240D153000255B8B /* CBManagerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBManagerState.swift; sourceTree = "<group>"; };
133
-		43DFBE3F2441A37B004A47EA /* BorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderView.swift; sourceTree = "<group>"; };
134 147
 		43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothSerial.swift; sourceTree = "<group>"; };
135 148
 		43F7792A2465AE1600745DF4 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
149
+		56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterMappingDebugView.swift; sourceTree = "<group>"; };
150
+		7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameStore.swift; sourceTree = "<group>"; };
151
+		AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDetailView.swift; sourceTree = "<group>"; };
152
+		AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
153
+		D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
154
+		D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
155
+		D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
156
+		D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsTabView.swift; sourceTree = "<group>"; };
157
+		D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoCardView.swift; sourceTree = "<group>"; };
158
+		D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoRowView.swift; sourceTree = "<group>"; };
159
+		D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameEditorView.swift; sourceTree = "<group>"; };
160
+		D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTimeoutEditorView.swift; sourceTree = "<group>"; };
161
+		D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBrightnessEditorView.swift; sourceTree = "<group>"; };
162
+		D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveMetricRange.swift; sourceTree = "<group>"; };
163
+		D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResistanceIconView.swift; sourceTree = "<group>"; };
164
+		D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlButtonView.swift; sourceTree = "<group>"; };
165
+		D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterCurrentScreenSummaryView.swift; sourceTree = "<group>"; };
166
+		D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordMetricsTableView.swift; sourceTree = "<group>"; };
167
+		D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionStatusBadgeView.swift; sourceTree = "<group>"; };
168
+		D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionActionView.swift; sourceTree = "<group>"; };
169
+		D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterOverviewSectionView.swift; sourceTree = "<group>"; };
136 170
 /* End PBXFileReference section */
137 171
 
172
+/* Begin PBXFileSystemSynchronizedRootGroup section */
173
+		43BE08E12F78F49500250EEC /* SidebarList */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SidebarList; sourceTree = "<group>"; };
174
+/* End PBXFileSystemSynchronizedRootGroup section */
175
+
138 176
 /* Begin PBXFrameworksBuildPhase section */
139 177
 		43CBF659240BF3EB00255B8B /* Frameworks */ = {
140 178
 			isa = PBXFrameworksBuildPhase;
@@ -241,21 +279,21 @@
241 279
 			path = "Vendor Contacts";
242 280
 			sourceTree = "<group>";
243 281
 		};
244
-		4308CF89241777130002E80B /* Data Groups */ = {
282
+		4308CF89241777130002E80B /* DataGroups */ = {
245 283
 			isa = PBXGroup;
246 284
 			children = (
247
-				4308CF872417770D0002E80B /* DataGroupsView.swift */,
248
-				4308CF8524176CAB0002E80B /* DataGroupRowView.swift */,
285
+				4308CF872417770D0002E80B /* DataGroupsSheetView.swift */,
286
+				D28F11263C8E4A7A00A10036 /* Subviews */,
249 287
 			);
250
-			path = "Data Groups";
288
+			path = DataGroups;
251 289
 			sourceTree = "<group>";
252 290
 		};
253
-		432F6ED8246684060043912E /* Chart */ = {
291
+		432F6ED8246684060043912E /* Subviews */ = {
254 292
 			isa = PBXGroup;
255 293
 			children = (
256
-				437F0AB62463108F005DEBEC /* MeasurementChartView.swift */,
294
+				43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */,
257 295
 			);
258
-			path = Chart;
296
+			path = Subviews;
259 297
 			sourceTree = "<group>";
260 298
 		};
261 299
 		4347F01B28D717C1007EE7B1 /* Frameworks */ = {
@@ -265,28 +303,22 @@
265 303
 			name = Frameworks;
266 304
 			sourceTree = "<group>";
267 305
 		};
268
-		43554B3024444983004E66F5 /* Measurements */ = {
306
+		43554B3024444983004E66F5 /* MeasurementSeries */ = {
269 307
 			isa = PBXGroup;
270 308
 			children = (
271
-				43554B2E24443939004E66F5 /* MeasurementsView.swift */,
272
-				43554B31244449B5004E66F5 /* MeasurementPointView.swift */,
273
-				432F6ED8246684060043912E /* Chart */,
309
+				43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */,
310
+				432F6ED8246684060043912E /* Subviews */,
274 311
 			);
275
-			path = Measurements;
312
+			path = MeasurementSeries;
276 313
 			sourceTree = "<group>";
277 314
 		};
278 315
 		437D47CF2415F8CF00B7768E /* Meter */ = {
279 316
 			isa = PBXGroup;
280 317
 			children = (
281 318
 				4383B469240FE4A600DAAEBF /* MeterView.swift */,
282
-				4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */,
283
-				437D47D02415F91B00B7768E /* LiveView.swift */,
284
-				437D47D42415FD8C00B7768E /* RecordingView.swift */,
285
-				437D47D62415FDF300B7768E /* ControlView.swift */,
286
-				4308CF89241777130002E80B /* Data Groups */,
287
-				4360A34C241CBB3800B464F9 /* RSSIView.swift */,
288
-				430CB4FB245E07EB006525C2 /* ChevronView.swift */,
289
-				43554B3024444983004E66F5 /* Measurements */,
319
+				D28F113F3C8E4A7A00A1004F /* Components */,
320
+				D28F11093C8E4A7A00A10019 /* Tabs */,
321
+				D28F10013C8E4A7A00A10001 /* Sheets */,
290 322
 			);
291 323
 			path = Meter;
292 324
 			sourceTree = "<group>";
@@ -295,7 +327,6 @@
295 327
 			isa = PBXGroup;
296 328
 			children = (
297 329
 				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
298
-				43567FE82443AD7C00000282 /* ICloudDefault.swift */,
299 330
 			);
300 331
 			path = Templates;
301 332
 			sourceTree = "<group>";
@@ -357,6 +388,7 @@
357 388
 			isa = PBXGroup;
358 389
 			children = (
359 390
 				4383B461240EB5E400DAAEBF /* AppData.swift */,
391
+				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
360 392
 				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
361 393
 				4383B45F240EB2D000DAAEBF /* Meter.swift */,
362 394
 				43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */,
@@ -364,7 +396,6 @@
364 396
 				4386958E2F6A4E3E008855A9 /* MeterCapabilities.swift */,
365 397
 				4386958A2F6A1001008855A9 /* UMProtocol.swift */,
366 398
 				4386958C2F6A1002008855A9 /* TC66Protocol.swift */,
367
-				43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */,
368 399
 				438695882463F062008855A9 /* Measurements.swift */,
369 400
 				432EA6432445A559006FC905 /* ChartContext.swift */,
370 401
 			);
@@ -375,10 +406,13 @@
375 406
 			isa = PBXGroup;
376 407
 			children = (
377 408
 				43CBF666240BF3EB00255B8B /* ContentView.swift */,
409
+				AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */,
410
+				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
378 411
 				4327461A24619CED0009BE4B /* MeterRowView.swift */,
379 412
 				437D47CF2415F8CF00B7768E /* Meter */,
413
+				D28F10023C8E4A7A00A10002 /* Components */,
380 414
 				4311E639241384960080EA59 /* DeviceHelpView.swift */,
381
-				43DFBE3F2441A37B004A47EA /* BorderView.swift */,
415
+				AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */,
382 416
 			);
383 417
 			path = Views;
384 418
 			sourceTree = "<group>";
@@ -399,6 +433,156 @@
399 433
 			path = Extensions;
400 434
 			sourceTree = "<group>";
401 435
 		};
436
+		AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */ = {
437
+			isa = PBXGroup;
438
+			children = (
439
+				AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */,
440
+				43BE08E12F78F49500250EEC /* SidebarList */,
441
+			);
442
+			path = Sidebar;
443
+			sourceTree = "<group>";
444
+		};
445
+		D28F10013C8E4A7A00A10001 /* Sheets */ = {
446
+			isa = PBXGroup;
447
+			children = (
448
+				4308CF89241777130002E80B /* DataGroups */,
449
+				43554B3024444983004E66F5 /* MeasurementSeries */,
450
+				D28F11273C8E4A7A00A10037 /* ChargeRecord */,
451
+			);
452
+			path = Sheets;
453
+			sourceTree = "<group>";
454
+		};
455
+		D28F10023C8E4A7A00A10002 /* Components */ = {
456
+			isa = PBXGroup;
457
+			children = (
458
+				D28F10033C8E4A7A00A10003 /* Generic */,
459
+			);
460
+			path = Components;
461
+			sourceTree = "<group>";
462
+		};
463
+		D28F10033C8E4A7A00A10003 /* Generic */ = {
464
+			isa = PBXGroup;
465
+			children = (
466
+				4360A34C241CBB3800B464F9 /* RSSIView.swift */,
467
+				430CB4FB245E07EB006525C2 /* ChevronView.swift */,
468
+			);
469
+			path = Generic;
470
+			sourceTree = "<group>";
471
+		};
472
+		D28F11093C8E4A7A00A10019 /* Tabs */ = {
473
+			isa = PBXGroup;
474
+			children = (
475
+				D28F110A3C8E4A7A00A1001A /* Home */,
476
+				D28F110B3C8E4A7A00A1001B /* Live */,
477
+				D28F110C3C8E4A7A00A1001C /* Chart */,
478
+				D28F110D3C8E4A7A00A1001D /* Settings */,
479
+			);
480
+			path = Tabs;
481
+			sourceTree = "<group>";
482
+		};
483
+		D28F110A3C8E4A7A00A1001A /* Home */ = {
484
+			isa = PBXGroup;
485
+			children = (
486
+				D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */,
487
+				D28F111B3C8E4A7A00A1002B /* Subviews */,
488
+			);
489
+			path = Home;
490
+			sourceTree = "<group>";
491
+		};
492
+		D28F110B3C8E4A7A00A1001B /* Live */ = {
493
+			isa = PBXGroup;
494
+			children = (
495
+				D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */,
496
+				D28F11253C8E4A7A00A10035 /* Subviews */,
497
+			);
498
+			path = Live;
499
+			sourceTree = "<group>";
500
+		};
501
+		D28F110C3C8E4A7A00A1001C /* Chart */ = {
502
+			isa = PBXGroup;
503
+			children = (
504
+				D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */,
505
+			);
506
+			path = Chart;
507
+			sourceTree = "<group>";
508
+		};
509
+		D28F110D3C8E4A7A00A1001D /* Settings */ = {
510
+			isa = PBXGroup;
511
+			children = (
512
+				D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */,
513
+				D28F111C3C8E4A7A00A1002C /* Subviews */,
514
+			);
515
+			path = Settings;
516
+			sourceTree = "<group>";
517
+		};
518
+		D28F111B3C8E4A7A00A1002B /* Subviews */ = {
519
+			isa = PBXGroup;
520
+			children = (
521
+				D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */,
522
+				D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */,
523
+				D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */,
524
+			);
525
+			path = Subviews;
526
+			sourceTree = "<group>";
527
+		};
528
+		D28F111C3C8E4A7A00A1002C /* Subviews */ = {
529
+			isa = PBXGroup;
530
+			children = (
531
+				D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */,
532
+				D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */,
533
+				D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */,
534
+				437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */,
535
+				D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */,
536
+				D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */,
537
+			);
538
+			path = Subviews;
539
+			sourceTree = "<group>";
540
+		};
541
+		D28F11253C8E4A7A00A10035 /* Subviews */ = {
542
+			isa = PBXGroup;
543
+			children = (
544
+				437D47D02415F91B00B7768E /* MeterLiveContentView.swift */,
545
+				D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */,
546
+				D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */,
547
+			);
548
+			path = Subviews;
549
+			sourceTree = "<group>";
550
+		};
551
+		D28F11263C8E4A7A00A10036 /* Subviews */ = {
552
+			isa = PBXGroup;
553
+			children = (
554
+				4308CF8524176CAB0002E80B /* DataGroupRowView.swift */,
555
+			);
556
+			path = Subviews;
557
+			sourceTree = "<group>";
558
+		};
559
+		D28F11273C8E4A7A00A10037 /* ChargeRecord */ = {
560
+			isa = PBXGroup;
561
+			children = (
562
+				437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */,
563
+				D28F11383C8E4A7A00A10048 /* Subviews */,
564
+			);
565
+			path = ChargeRecord;
566
+			sourceTree = "<group>";
567
+		};
568
+		D28F11383C8E4A7A00A10048 /* Subviews */ = {
569
+			isa = PBXGroup;
570
+			children = (
571
+				D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */,
572
+			);
573
+			path = Subviews;
574
+			sourceTree = "<group>";
575
+		};
576
+		D28F113F3C8E4A7A00A1004F /* Components */ = {
577
+			isa = PBXGroup;
578
+			children = (
579
+				D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */,
580
+				D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */,
581
+				437F0AB62463108F005DEBEC /* MeasurementChartView.swift */,
582
+			);
583
+			path = Components;
584
+			sourceTree = "<group>";
585
+		};
402 586
 /* End PBXGroup section */
403 587
 
404 588
 /* Begin PBXNativeTarget section */
@@ -414,6 +598,9 @@
414 598
 			);
415 599
 			dependencies = (
416 600
 			);
601
+			fileSystemSynchronizedGroups = (
602
+				43BE08E12F78F49500250EEC /* SidebarList */,
603
+			);
417 604
 			name = "USB Meter";
418 605
 			packageProductDependencies = (
419 606
 				4347F01C28D717C1007EE7B1 /* CryptoSwift */,
@@ -435,6 +622,11 @@
435 622
 				TargetAttributes = {
436 623
 					43CBF65B240BF3EB00255B8B = {
437 624
 						CreatedOnToolsVersion = 11.3.1;
625
+						SystemCapabilities = {
626
+							com.apple.iCloud = {
627
+								enabled = 1;
628
+							};
629
+						};
438 630
 					};
439 631
 				};
440 632
 			};
@@ -478,42 +670,59 @@
478 670
 			buildActionMask = 2147483647;
479 671
 			files = (
480 672
 				43874C852415611200525397 /* Double.swift in Sources */,
481
-				437D47D72415FDF300B7768E /* ControlView.swift in Sources */,
482
-				4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */,
483
-				43567FE92443AD7C00000282 /* ICloudDefault.swift in Sources */,
673
+				437D47D72415FDF300B7768E /* MeterScreenControlsView.swift in Sources */,
674
+				4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */,
484 675
 				4383B468240F845500DAAEBF /* MacAdress.swift in Sources */,
485 676
 				43CBF681240D153000255B8B /* CBManagerState.swift in Sources */,
486 677
 				4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */,
678
+				D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */,
679
+				D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */,
680
+				D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */,
681
+				D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */,
682
+				D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */,
683
+				D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */,
684
+				D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */,
685
+				D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */,
686
+				D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */,
687
+				D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */,
688
+				D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */,
689
+				D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */,
690
+				D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */,
691
+				D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */,
692
+				D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */,
693
+				D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */,
694
+				D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */,
487 695
 				4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */,
488
-				437D47D12415F91B00B7768E /* LiveView.swift in Sources */,
489
-				43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */,
490
-				4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */,
696
+				437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */,
491 697
 				4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */,
698
+				3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */,
492 699
 				43CBF677240C043E00255B8B /* BluetoothManager.swift in Sources */,
493 700
 				43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */,
494 701
 				438B9555246D2D7500E61AE7 /* Path.swift in Sources */,
495 702
 				4383B460240EB2D000DAAEBF /* Meter.swift in Sources */,
703
+				AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */,
496 704
 				43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */,
497
-				43DFBE402441A37B004A47EA /* BorderView.swift in Sources */,
498 705
 				437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */,
499 706
 				437D47D32415FB7E00B7768E /* Decimal.swift in Sources */,
500 707
 				43874C7F2414F3F400525397 /* Float.swift in Sources */,
501 708
 				4383B462240EB5E400DAAEBF /* AppData.swift in Sources */,
502 709
 				4386958D2F6A1002008855A9 /* TC66Protocol.swift in Sources */,
503
-				437D47D52415FD8C00B7768E /* RecordingView.swift in Sources */,
710
+				437D47D52415FD8C00B7768E /* ChargeRecordSheetView.swift in Sources */,
504 711
 				432EA6442445A559006FC905 /* ChartContext.swift in Sources */,
505 712
 				4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */,
506 713
 				4386958F2F6A4E3E008855A9 /* MeterCapabilities.swift in Sources */,
507
-				43554B32244449B5004E66F5 /* MeasurementPointView.swift in Sources */,
714
+				43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */,
508 715
 				43F7792B2465AE1600745DF4 /* UIView.swift in Sources */,
509 716
 				43ED78AE2420A0BE00974487 /* BluetoothSerial.swift in Sources */,
510 717
 				43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */,
511 718
 				4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */,
512 719
 				4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */,
513
-				43554B2F24443939004E66F5 /* MeasurementsView.swift in Sources */,
720
+				43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */,
514 721
 				430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */,
515 722
 				43554B3424444B0E004E66F5 /* Date.swift in Sources */,
516 723
 				4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */,
724
+				AAD5F9A72B1CAC0700F8E4F9 /* MeterDetailView.swift in Sources */,
725
+				E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */,
517 726
 				439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */,
518 727
 				438695892463F062008855A9 /* Measurements.swift in Sources */,
519 728
 				4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */,
@@ -675,7 +884,10 @@
675 884
 				PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter";
676 885
 				PRODUCT_NAME = "$(TARGET_NAME)";
677 886
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
887
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
678 888
 				SUPPORTS_MACCATALYST = YES;
889
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
890
+				SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
679 891
 				SWIFT_VERSION = 5.0;
680 892
 				TARGETED_DEVICE_FAMILY = "1,2";
681 893
 			};
@@ -699,7 +911,10 @@
699 911
 				PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter";
700 912
 				PRODUCT_NAME = "$(TARGET_NAME)";
701 913
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
914
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
702 915
 				SUPPORTS_MACCATALYST = YES;
916
+				SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
917
+				SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
703 918
 				SWIFT_VERSION = 5.0;
704 919
 				TARGETED_DEVICE_FAMILY = "1,2";
705 920
 			};
@@ -746,19 +961,6 @@
746 961
 			productName = CryptoSwift;
747 962
 		};
748 963
 /* End XCSwiftPackageProductDependency section */
749
-
750
-/* Begin XCVersionGroup section */
751
-		43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */ = {
752
-			isa = XCVersionGroup;
753
-			children = (
754
-				43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */,
755
-			);
756
-			currentVersion = 43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */;
757
-			path = CKModel.xcdatamodeld;
758
-			sourceTree = "<group>";
759
-			versionGroupType = wrapper.xcdatamodel;
760
-		};
761
-/* End XCVersionGroup section */
762 964
 	};
763 965
 	rootObject = 43CBF654240BF3EB00255B8B /* Project object */;
764 966
 }
+99 -49
USB Meter/AppDelegate.swift
@@ -7,7 +7,6 @@
7 7
 //
8 8
 
9 9
 import UIKit
10
-import CoreData
11 10
 
12 11
 //let btSerial = BluetoothSerial(delegate: BSD())
13 12
 let appData = AppData()
@@ -19,6 +18,9 @@ enum Constants {
19 18
 
20 19
 // MARK: Debug
21 20
 public func track(_ message: String = "", file: String = #file, function: String = #function, line: Int = #line ) {
21
+    guard shouldEmitTrackMessage(message, file: file, function: function) else {
22
+        return
23
+    }
22 24
     let date = Date()
23 25
     let calendar = Calendar.current
24 26
     let hour = calendar.component(.hour, from: date)
@@ -27,15 +29,110 @@ public func track(_ message: String = "", file: String = #file, function: String
27 29
     print("\(hour):\(minutes):\(seconds) - \(file):\(line) - \(function) \(message)")
28 30
 }
29 31
 
32
+private func shouldEmitTrackMessage(_ message: String, file: String, function: String) -> Bool {
33
+    #if DEBUG
34
+    if ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" {
35
+        return true
36
+    }
37
+
38
+    #if targetEnvironment(macCatalyst)
39
+    let importantMarkers = [
40
+        "Error",
41
+        "error",
42
+        "Failed",
43
+        "failed",
44
+        "timeout",
45
+        "Timeout",
46
+        "Missing",
47
+        "missing",
48
+        "overflow",
49
+        "Disconnect",
50
+        "disconnect",
51
+        "Disconnected",
52
+        "unauthorized",
53
+        "not authorized",
54
+        "not supported",
55
+        "Unexpected",
56
+        "Invalid Context",
57
+        "ignored",
58
+        "Guard:",
59
+        "Skip data request",
60
+        "Dropping unsolicited data",
61
+        "This is not possible!",
62
+        "Inferred",
63
+        "Clearing",
64
+        "Reconnecting"
65
+    ]
66
+
67
+    if importantMarkers.contains(where: { message.contains($0) }) {
68
+        return true
69
+    }
70
+
71
+    let noisyFunctions: Set<String> = [
72
+        "logRuntimeICloudDiagnostics()",
73
+        "refreshCloudAvailability(reason:)",
74
+        "start()",
75
+        "centralManagerDidUpdateState(_:)",
76
+        "discoveredMeter(peripheral:advertising:rssi:)",
77
+        "connect()",
78
+        "connectionEstablished()",
79
+        "peripheral(_:didDiscoverServices:)",
80
+        "peripheral(_:didDiscoverCharacteristicsFor:error:)",
81
+        "refreshOperationalStateIfReady()",
82
+        "peripheral(_:didUpdateNotificationStateFor:error:)",
83
+        "scheduleDataDumpRequest(after:reason:)"
84
+    ]
85
+
86
+    if noisyFunctions.contains(function) {
87
+        return false
88
+    }
89
+
90
+    let noisyMarkers = [
91
+        "Runtime iCloud diagnostics",
92
+        "iCloud availability",
93
+        "Starting Bluetooth manager",
94
+        "Bluetooth is On... Start scanning...",
95
+        "adding new USB Meter",
96
+        "Connect called for",
97
+        "Connection established for",
98
+        "Optional([<CBService:",
99
+        "Optional([<CBCharacteristic:",
100
+        "Waiting for notifications on",
101
+        "Notification state updated for",
102
+        "Peripheral ready with notify",
103
+        "Schedule data request in",
104
+        "Operational state changed"
105
+    ]
106
+
107
+    if noisyMarkers.contains(where: { message.contains($0) }) {
108
+        return false
109
+    }
110
+    #endif
111
+
112
+    return true
113
+    #else
114
+    _ = file
115
+    _ = function
116
+    return false
117
+    #endif
118
+}
119
+
30 120
 @UIApplicationMain
31 121
 class AppDelegate: UIResponder, UIApplicationDelegate {
32 122
 
33 123
 
34 124
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
35
-        // Override point for customization after application launch.
125
+        logRuntimeICloudDiagnostics()
36 126
         return true
37 127
     }
38 128
 
129
+    private func logRuntimeICloudDiagnostics() {
130
+        #if DEBUG
131
+        let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil
132
+        track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
133
+        #endif
134
+    }
135
+
39 136
     // MARK: UISceneSession Lifecycle
40 137
 
41 138
     func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
@@ -49,51 +146,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
49 146
         // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
50 147
         // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
51 148
     }
52
-
53
-    // MARK: - Core Data stack
54
-
55
-    lazy var persistentContainer: NSPersistentCloudKitContainer = {
56
-        /*
57
-         The persistent container for the application. This implementation
58
-         creates and returns a container, having loaded the store for the
59
-         application to it. This property is optional since there are legitimate
60
-         error conditions that could cause the creation of the store to fail.
61
-        */
62
-        let container = NSPersistentCloudKitContainer(name: "CKModel")
63
-        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
64
-            if let error = error as NSError? {
65
-                // Replace this implementation with code to handle the error appropriately.
66
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
67
-                 
68
-                /*
69
-                 Typical reasons for an error here include:
70
-                 * The parent directory does not exist, cannot be created, or disallows writing.
71
-                 * The persistent store is not accessible, due to permissions or data protection when the device is locked.
72
-                 * The device is out of space.
73
-                 * The store could not be migrated to the current model version.
74
-                 Check the error message to determine what the actual problem was.
75
-                 */
76
-                fatalError("Unresolved error \(error), \(error.userInfo)")
77
-            }
78
-        })
79
-        return container
80
-    }()
81
-
82
-    // MARK: - Core Data Saving support
83
-
84
-    func saveContext () {
85
-        let context = persistentContainer.viewContext
86
-        if context.hasChanges {
87
-            do {
88
-                try context.save()
89
-            } catch {
90
-                // Replace this implementation with code to handle the error appropriately.
91
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
92
-                let nserror = error as NSError
93
-                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
94
-            }
95
-        }
96
-    }
97
-
98 149
 }
99
-
+162 -37
USB Meter/Model/AppData.swift
@@ -11,55 +11,119 @@ import Combine
11 11
 import CoreBluetooth
12 12
 
13 13
 final class AppData : ObservableObject {
14
-    private var icloudGefaultsNotification: AnyCancellable?
14
+    struct MeterSummary: Identifiable {
15
+        let macAddress: String
16
+        let displayName: String
17
+        let modelSummary: String
18
+        let advertisedName: String?
19
+        let lastSeen: Date?
20
+        let lastConnected: Date?
21
+        let meter: Meter?
22
+
23
+        var id: String {
24
+            macAddress
25
+        }
26
+    }
27
+
15 28
     private var bluetoothManagerNotification: AnyCancellable?
29
+    private var meterStoreObserver: AnyCancellable?
30
+    private var meterStoreCloudObserver: AnyCancellable?
31
+    private let meterStore = MeterNameStore.shared
16 32
 
17 33
     init() {
18
-        icloudGefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: test)
19 34
         bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
20 35
             self?.scheduleObjectWillChange()
21 36
         }
22
-        //NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: { notification in
23
-        
37
+        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
38
+            .receive(on: DispatchQueue.main)
39
+            .sink { [weak self] _ in
40
+                self?.refreshMeterMetadata()
41
+            }
42
+        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
43
+            .receive(on: DispatchQueue.main)
44
+            .sink { [weak self] _ in
45
+                self?.scheduleObjectWillChange()
46
+            }
24 47
     }
25
-    
48
+
26 49
     let bluetoothManager = BluetoothManager()
27
-    
50
+
28 51
     @Published var enableRecordFeature: Bool = true
29
-    
52
+
30 53
     @Published var meters: [UUID:Meter] = [UUID:Meter]()
31
-    
32
-    @ICloudDefault(key: "MeterNames", defaultValue: [:]) var meterNames: [String:String]
33
-    @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) var tc66TemperatureUnits: [String:String]
34
-    func test(notification: NotificationCenter.Publisher.Output) -> Void {
35
-        if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
36
-            var somethingChanged = false
37
-            for changedKey in changedKeys {
38
-                switch changedKey {
39
-                case "MeterNames":
40
-                    for meter in self.meters.values {
41
-                        if let newName = self.meterNames[meter.btSerial.macAddress.description] {
42
-                            if meter.name != newName {
43
-                                meter.name = newName
44
-                                somethingChanged = true
45
-                            }
46
-                        }
47
-                    }
48
-                case "TC66TemperatureUnits":
49
-                    for meter in self.meters.values where meter.supportsManualTemperatureUnitSelection {
50
-                        meter.reloadTemperatureUnitPreference()
51
-                        somethingChanged = true
52
-                    }
53
-                default:
54
-                    track("Unknown key: '\(changedKey)' changed in iCloud)")
55
-                }
56
-                if changedKey == "MeterNames" {
57
-                    
58
-                }
54
+
55
+    var cloudAvailability: MeterNameStore.CloudAvailability {
56
+        meterStore.currentCloudAvailability
57
+    }
58
+
59
+    func meterName(for macAddress: String) -> String? {
60
+        meterStore.name(for: macAddress)
61
+    }
62
+
63
+    func setMeterName(_ name: String, for macAddress: String) {
64
+        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
65
+    }
66
+
67
+    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
68
+        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
69
+        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
70
+    }
71
+
72
+    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
73
+        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
74
+    }
75
+
76
+    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
77
+        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
78
+    }
79
+
80
+    func noteMeterSeen(at date: Date, macAddress: String) {
81
+        meterStore.noteLastSeen(date, for: macAddress)
82
+    }
83
+
84
+    func noteMeterConnected(at date: Date, macAddress: String) {
85
+        meterStore.noteLastConnected(date, for: macAddress)
86
+    }
87
+
88
+    func lastSeen(for macAddress: String) -> Date? {
89
+        meterStore.lastSeen(for: macAddress)
90
+    }
91
+
92
+    func lastConnected(for macAddress: String) -> Date? {
93
+        meterStore.lastConnected(for: macAddress)
94
+    }
95
+
96
+    var meterSummaries: [MeterSummary] {
97
+        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
98
+        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
99
+        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
100
+
101
+        return macAddresses.map { macAddress in
102
+            let liveMeter = liveMetersByMAC[macAddress]
103
+            let record = recordsByMAC[macAddress]
104
+
105
+            return MeterSummary(
106
+                macAddress: macAddress,
107
+                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
108
+                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
109
+                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
110
+                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
111
+                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
112
+                meter: liveMeter
113
+            )
114
+        }
115
+        .sorted { lhs, rhs in
116
+            if lhs.meter != nil && rhs.meter == nil {
117
+                return true
59 118
             }
60
-            if somethingChanged {
61
-                scheduleObjectWillChange()
119
+            if lhs.meter == nil && rhs.meter != nil {
120
+                return false
62 121
             }
122
+            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
123
+            if byName != .orderedSame {
124
+                return byName == .orderedAscending
125
+            }
126
+            return lhs.macAddress < rhs.macAddress
63 127
         }
64 128
     }
65 129
 
@@ -68,4 +132,65 @@ final class AppData : ObservableObject {
68 132
             self?.objectWillChange.send()
69 133
         }
70 134
     }
135
+
136
+    private func refreshMeterMetadata() {
137
+        DispatchQueue.main.async { [weak self] in
138
+            guard let self else { return }
139
+            var didUpdateAnyMeter = false
140
+            for meter in self.meters.values {
141
+                let mac = meter.btSerial.macAddress.description
142
+                let displayName = self.meterName(for: mac) ?? mac
143
+                if meter.name != displayName {
144
+                    meter.updateNameFromStore(displayName)
145
+                    didUpdateAnyMeter = true
146
+                }
147
+
148
+                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
149
+                meter.reloadTemperatureUnitPreference()
150
+                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
151
+                    didUpdateAnyMeter = true
152
+                }
153
+            }
154
+
155
+            if didUpdateAnyMeter {
156
+                self.scheduleObjectWillChange()
157
+            }
158
+        }
159
+    }
160
+}
161
+
162
+extension AppData.MeterSummary {
163
+    var tint: Color {
164
+        switch modelSummary {
165
+        case "UM25C":
166
+            return .blue
167
+        case "UM34C":
168
+            return .yellow
169
+        case "TC66C":
170
+            return Model.TC66C.color
171
+        default:
172
+            return .secondary
173
+        }
174
+    }
175
+}
176
+
177
+private extension AppData {
178
+    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
179
+        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
180
+            return liveName
181
+        }
182
+        if let customName = record?.customName {
183
+            return customName
184
+        }
185
+        if let advertisedName = record?.advertisedName {
186
+            return advertisedName
187
+        }
188
+        if let recordModel = record?.modelName {
189
+            return recordModel
190
+        }
191
+        if let liveModel = liveMeter?.deviceModelSummary {
192
+            return liveModel
193
+        }
194
+        return "Meter"
195
+    }
71 196
 }
+19 -3
USB Meter/Model/BluetoothManager.swift
@@ -61,6 +61,9 @@ class BluetoothManager : NSObject, ObservableObject {
61 61
         }
62 62
         
63 63
         let macAddress = MACAddress(from: manufacturerData.suffix(from: 2))
64
+        let macAddressString = macAddress.description
65
+        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
66
+        appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
64 67
         
65 68
         if appData.meters[peripheral.identifier] == nil {
66 69
             track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
@@ -71,6 +74,10 @@ class BluetoothManager : NSObject, ObservableObject {
71 74
         } else if let meter = appData.meters[peripheral.identifier] {
72 75
             meter.lastSeen = Date()
73 76
             meter.btSerial.updateRSSI(RSSI.intValue)
77
+            let macAddress = meter.btSerial.macAddress.description
78
+            if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
79
+                meter.updateNameFromStore(syncedName)
80
+            }
74 81
             if peripheral.delegate == nil {
75 82
                 peripheral.delegate = meter.btSerial
76 83
             }
@@ -108,10 +115,14 @@ extension BluetoothManager : CBCentralManagerDelegate {
108 115
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
109 116
         managerState = central.state;
110 117
         track("\(central.state)")
118
+        for meter in appData.meters.values {
119
+            meter.btSerial.centralStateChanged(to: central.state)
120
+        }
111 121
         
112 122
         switch central.state {
113 123
         case .poweredOff:
114 124
             scanStartedAt = nil
125
+            advertisementDataCache.clear()
115 126
             track("Bluetooth is Off. How should I behave?")
116 127
         case .poweredOn:
117 128
             scanStartedAt = Date()
@@ -122,18 +133,23 @@ extension BluetoothManager : CBCentralManagerDelegate {
122 133
             scanForMeters()
123 134
         case .resetting:
124 135
             scanStartedAt = nil
136
+            advertisementDataCache.clear()
125 137
             track("Bluetooth is reseting... . Whatever that means.")
126 138
         case .unauthorized:
127 139
             scanStartedAt = nil
140
+            advertisementDataCache.clear()
128 141
             track("Bluetooth is not authorized.")
129 142
         case .unknown:
130 143
             scanStartedAt = nil
144
+            advertisementDataCache.clear()
131 145
             track("Bluetooth is in an unknown state.")
132 146
         case .unsupported:
133 147
             scanStartedAt = nil
148
+            advertisementDataCache.clear()
134 149
             track("Bluetooth not supported by device")
135 150
         default:
136 151
             scanStartedAt = nil
152
+            advertisementDataCache.clear()
137 153
             track("Bluetooth is in a state never seen before!")
138 154
         }
139 155
     }
@@ -152,7 +168,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
152 168
             usbMeter.btSerial.connectionEstablished()
153 169
         }
154 170
         else {
155
-            track("Connected to unknown meter with UUID: '\(peripheral.identifier)'")
171
+            track("Connected to meter with UUID: '\(peripheral.identifier)'")
156 172
         }
157 173
     }
158 174
     
@@ -163,7 +179,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
163 179
             usbMeter.btSerial.connectionClosed()
164 180
         }
165 181
         else {
166
-            track("Disconnected from unknown meter with UUID: '\(peripheral.identifier)'")
182
+            track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
167 183
         }
168 184
     }
169 185
 
@@ -172,7 +188,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
172 188
         if let usbMeter = appData.meters[peripheral.identifier] {
173 189
             usbMeter.btSerial.connectionClosed()
174 190
         } else {
175
-            track("Failed to connect to unknown meter with UUID: '\(peripheral.identifier)'")
191
+            track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
176 192
         }
177 193
     }
178 194
 }
+38 -0
USB Meter/Model/BluetoothSerial.swift
@@ -108,9 +108,22 @@ final class BluetoothSerial : NSObject, ObservableObject {
108 108
             notifyCharacteristic = nil
109 109
         }
110 110
     }
111
+
112
+    private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) {
113
+        resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics)
114
+        guard operationalState != .peripheralNotConnected else {
115
+            return
116
+        }
117
+        operationalState = .peripheralNotConnected
118
+    }
111 119
     
112 120
     func connect() {
113 121
         administrativeState = .up
122
+        guard manager.state == .poweredOn else {
123
+            track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)")
124
+            forceNotConnected(reason: "connect() while central is \(manager.state)")
125
+            return
126
+        }
114 127
         if operationalState < .peripheralConnected {
115 128
             resetCommunicationState(reason: "connect()", clearCharacteristics: true)
116 129
             operationalState = .peripheralConnectionPending
@@ -126,6 +139,11 @@ final class BluetoothSerial : NSObject, ObservableObject {
126 139
         resetCommunicationState(reason: "disconnect()", clearCharacteristics: true)
127 140
         if peripheral.state != .disconnected || operationalState != .peripheralNotConnected {
128 141
             track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
142
+            guard manager.state == .poweredOn else {
143
+                track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)")
144
+                forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false)
145
+                return
146
+            }
129 147
             manager.cancelPeripheralConnection(peripheral)
130 148
         }
131 149
     }
@@ -186,6 +204,26 @@ final class BluetoothSerial : NSObject, ObservableObject {
186 204
         operationalState = .peripheralNotConnected
187 205
     }
188 206
 
207
+    func centralStateChanged(to newState: CBManagerState) {
208
+        switch newState {
209
+        case .poweredOn:
210
+            if administrativeState == .up,
211
+               operationalState == .peripheralNotConnected,
212
+               peripheral.state == .disconnected {
213
+                track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'")
214
+                connect()
215
+            }
216
+        case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported:
217
+            if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty {
218
+                track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.")
219
+            }
220
+            forceNotConnected(reason: "centralStateChanged(\(newState))")
221
+        @unknown default:
222
+            track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.")
223
+            forceNotConnected(reason: "centralStateChanged(@unknown default)")
224
+        }
225
+    }
226
+
189 227
     func setWDT() {
190 228
         wdTimer?.invalidate()
191 229
         wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in
+0 -8
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -1,8 +0,0 @@
1
-<?xml version="1.0" encoding="UTF-8"?>
2
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
-<plist version="1.0">
4
-<dict>
5
-	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter.xcdatamodel</string>
7
-</dict>
8
-</plist>
+0 -7
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter.xcdatamodel/contents
@@ -1,7 +0,0 @@
1
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19E287" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
3
-    <entity name="Entity" representedClassName="Entity" syncable="YES" codeGenerationType="class"/>
4
-    <elements>
5
-        <element name="Entity" positionX="-63" positionY="-18" width="128" height="43"/>
6
-    </elements>
7
-</model>
+10 -0
USB Meter/Model/ChartContext.swift
@@ -88,6 +88,16 @@ class ChartContext {
88 88
         self.rect = rect
89 89
         padding()
90 90
     }
91
+
92
+    func setBounds(xMin: CGFloat, xMax: CGFloat, yMin: CGFloat, yMax: CGFloat) {
93
+        rect = CGRect(
94
+            x: min(xMin, xMax),
95
+            y: min(yMin, yMax),
96
+            width: abs(xMax - xMin),
97
+            height: max(abs(yMax - yMin), 0.1)
98
+        )
99
+        padding()
100
+    }
91 101
     
92 102
     func yAxisLabel( for itemNo: Int, of items: Int ) -> Double {
93 103
         let labelSpace = Double(rect!.height) / Double(items - 1)
+293 -34
USB Meter/Model/Measurements.swift
@@ -13,9 +13,24 @@ class Measurements : ObservableObject {
13 13
 
14 14
     class Measurement : ObservableObject {
15 15
         struct Point : Identifiable , Hashable {
16
+            enum Kind: Hashable {
17
+                case sample
18
+                case discontinuity
19
+            }
20
+
16 21
             var id : Int
17 22
             var timestamp: Date
18 23
             var value: Double
24
+            var kind: Kind = .sample
25
+
26
+            var isSample: Bool {
27
+                kind == .sample
28
+            }
29
+
30
+            var isDiscontinuity: Bool {
31
+                kind == .discontinuity
32
+            }
33
+
19 34
             func point() -> CGPoint {
20 35
                 return CGPoint(x: timestamp.timeIntervalSince1970, y: value)
21 36
             }
@@ -24,23 +39,56 @@ class Measurements : ObservableObject {
24 39
         var points: [Point] = []
25 40
         var context = ChartContext()
26 41
 
42
+        var samplePoints: [Point] {
43
+            points.filter { $0.isSample }
44
+        }
45
+
46
+        func points(in range: ClosedRange<Date>) -> [Point] {
47
+            guard !points.isEmpty else { return [] }
48
+
49
+            let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound)
50
+            let endIndex = indexOfFirstPoint(after: range.upperBound)
51
+            guard startIndex < endIndex else { return [] }
52
+            return Array(points[startIndex..<endIndex])
53
+        }
54
+
55
+        private func rebuildContext() {
56
+            context.reset()
57
+            for point in points where point.isSample {
58
+                context.include(point: point.point())
59
+            }
60
+        }
61
+
62
+        private func appendPoint(timestamp: Date, value: Double, kind: Point.Kind) {
63
+            let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value, kind: kind)
64
+            points.append(newPoint)
65
+            if newPoint.isSample {
66
+                context.include(point: newPoint.point())
67
+            }
68
+            self.objectWillChange.send()
69
+        }
70
+
27 71
         func removeValue(index: Int) {
72
+            guard points.indices.contains(index) else { return }
28 73
             points.remove(at: index)
29
-            context.reset()
30
-            for point in points {
31
-                context.include( point: point.point() )
74
+            for index in points.indices {
75
+                points[index].id = index
32 76
             }
77
+            rebuildContext()
33 78
             self.objectWillChange.send()
34 79
         }
35 80
 
36 81
         func addPoint(timestamp: Date, value: Double) {
37
-            let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value)
38
-            points.append(newPoint)
39
-            context.include( point: newPoint.point() )
40
-            self.objectWillChange.send()
82
+            appendPoint(timestamp: timestamp, value: value, kind: .sample)
83
+        }
84
+
85
+        func addDiscontinuity(timestamp: Date) {
86
+            guard !points.isEmpty else { return }
87
+            guard points.last?.isDiscontinuity == false else { return }
88
+            appendPoint(timestamp: timestamp, value: points.last?.value ?? 0, kind: .discontinuity)
41 89
         }
42 90
         
43
-        func reset() {
91
+        func resetSeries() {
44 92
             points.removeAll()
45 93
             context.reset()
46 94
             self.objectWillChange.send()
@@ -51,76 +99,287 @@ class Measurements : ObservableObject {
51 99
                 .filter { $0.timestamp >= cutoff }
52 100
                 .enumerated()
53 101
                 .map { index, point in
54
-                    Measurement.Point(id: index, timestamp: point.timestamp, value: point.value)
102
+                    Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind)
55 103
                 }
56
-            context.reset()
57
-            for point in points {
58
-                context.include(point: point.point())
104
+            rebuildContext()
105
+            self.objectWillChange.send()
106
+        }
107
+
108
+        func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
109
+            let originalSamples = samplePoints
110
+            guard !originalSamples.isEmpty else { return }
111
+
112
+            var rebuiltPoints: [Point] = []
113
+            var lastKeptSampleIndex: Int?
114
+
115
+            for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
116
+                if let lastKeptSampleIndex {
117
+                    let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1
118
+                    let previousSample = originalSamples[lastKeptSampleIndex]
119
+                    let originalHadDiscontinuityBetween = points.contains { point in
120
+                        point.isDiscontinuity &&
121
+                        point.timestamp > previousSample.timestamp &&
122
+                        point.timestamp <= sample.timestamp
123
+                    }
124
+
125
+                    if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
126
+                        rebuiltPoints.append(
127
+                            Point(
128
+                                id: rebuiltPoints.count,
129
+                                timestamp: sample.timestamp,
130
+                                value: rebuiltPoints.last?.value ?? sample.value,
131
+                                kind: .discontinuity
132
+                            )
133
+                        )
134
+                    }
135
+                }
136
+
137
+                rebuiltPoints.append(
138
+                    Point(
139
+                        id: rebuiltPoints.count,
140
+                        timestamp: sample.timestamp,
141
+                        value: sample.value,
142
+                        kind: .sample
143
+                    )
144
+                )
145
+                lastKeptSampleIndex = sampleIndex
59 146
             }
147
+
148
+            points = rebuiltPoints
149
+            rebuildContext()
60 150
             self.objectWillChange.send()
61 151
         }
152
+
153
+        private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
154
+            var lowerBound = 0
155
+            var upperBound = points.count
156
+
157
+            while lowerBound < upperBound {
158
+                let midIndex = (lowerBound + upperBound) / 2
159
+                if points[midIndex].timestamp < date {
160
+                    lowerBound = midIndex + 1
161
+                } else {
162
+                    upperBound = midIndex
163
+                }
164
+            }
165
+
166
+            return lowerBound
167
+        }
168
+
169
+        private func indexOfFirstPoint(after date: Date) -> Int {
170
+            var lowerBound = 0
171
+            var upperBound = points.count
172
+
173
+            while lowerBound < upperBound {
174
+                let midIndex = (lowerBound + upperBound) / 2
175
+                if points[midIndex].timestamp <= date {
176
+                    lowerBound = midIndex + 1
177
+                } else {
178
+                    upperBound = midIndex
179
+                }
180
+            }
181
+
182
+            return lowerBound
183
+        }
62 184
     }
63 185
     
64 186
     @Published var power = Measurement()
65 187
     @Published var voltage = Measurement()
66 188
     @Published var current = Measurement()
189
+    @Published var temperature = Measurement()
190
+    @Published var energy = Measurement()
191
+    @Published var rssi = Measurement()
67 192
 
68
-    private var lastPointTimestamp = 0
193
+    let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
194
+
195
+    private var pendingBucketSecond: Int?
196
+    private var pendingBucketTimestamp: Date?
197
+    private let energyResetEpsilon = 0.0005
198
+    private var lastEnergyCounterValue: Double?
199
+    private var lastEnergyGroupID: UInt8?
200
+    private var accumulatedEnergyValue: Double = 0
69 201
     
70 202
     private var itemsInSum: Double = 0
71 203
     private var powerSum: Double = 0
72 204
     private var voltageSum: Double = 0
73 205
     private var currentSum: Double = 0
206
+    private var temperatureSum: Double = 0
207
+    private var rssiSum: Double = 0
74 208
 
75
-    func reset() {
76
-        power.reset()
77
-        voltage.reset()
78
-        current.reset()
79
-        lastPointTimestamp = 0
209
+    private func resetPendingAggregation() {
210
+        pendingBucketSecond = nil
211
+        pendingBucketTimestamp = nil
80 212
         itemsInSum = 0
81 213
         powerSum = 0
82 214
         voltageSum = 0
83 215
         currentSum = 0
216
+        temperatureSum = 0
217
+        rssiSum = 0
218
+    }
219
+
220
+    private func flushPendingValues() {
221
+        guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
222
+        self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
223
+        self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
224
+        self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
225
+        self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / itemsInSum)
226
+        self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
227
+        resetPendingAggregation()
228
+        self.objectWillChange.send()
229
+    }
230
+
231
+    func resetSeries() {
232
+        power.resetSeries()
233
+        voltage.resetSeries()
234
+        current.resetSeries()
235
+        temperature.resetSeries()
236
+        energy.resetSeries()
237
+        rssi.resetSeries()
238
+        resetPendingAggregation()
239
+        lastEnergyCounterValue = nil
240
+        lastEnergyGroupID = nil
241
+        accumulatedEnergyValue = 0
84 242
         self.objectWillChange.send()
85 243
     }
244
+
245
+    func reset() {
246
+        resetSeries()
247
+    }
86 248
     
87 249
     func remove(at idx: Int) {
88 250
         power.removeValue(index: idx)
89 251
         voltage.removeValue(index: idx)
90 252
         current.removeValue(index: idx)
253
+        temperature.removeValue(index: idx)
254
+        energy.removeValue(index: idx)
255
+        rssi.removeValue(index: idx)
256
+        lastEnergyCounterValue = nil
257
+        lastEnergyGroupID = nil
258
+        accumulatedEnergyValue = 0
91 259
         self.objectWillChange.send()
92 260
     }
93 261
 
94 262
     func trim(before cutoff: Date) {
263
+        flushPendingValues()
95 264
         power.trim(before: cutoff)
96 265
         voltage.trim(before: cutoff)
97 266
         current.trim(before: cutoff)
267
+        temperature.trim(before: cutoff)
268
+        energy.trim(before: cutoff)
269
+        rssi.trim(before: cutoff)
270
+        lastEnergyCounterValue = nil
271
+        lastEnergyGroupID = nil
272
+        accumulatedEnergyValue = 0
98 273
         self.objectWillChange.send()
99 274
     }
100 275
 
276
+    func keepOnly(in range: ClosedRange<Date>) {
277
+        flushPendingValues()
278
+        power.filterSamples { range.contains($0) }
279
+        voltage.filterSamples { range.contains($0) }
280
+        current.filterSamples { range.contains($0) }
281
+        temperature.filterSamples { range.contains($0) }
282
+        energy.filterSamples { range.contains($0) }
283
+        rssi.filterSamples { range.contains($0) }
284
+        lastEnergyCounterValue = nil
285
+        lastEnergyGroupID = nil
286
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
287
+        self.objectWillChange.send()
288
+    }
101 289
 
102
-        
103
-    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
290
+    func removeValues(in range: ClosedRange<Date>) {
291
+        flushPendingValues()
292
+        power.filterSamples { !range.contains($0) }
293
+        voltage.filterSamples { !range.contains($0) }
294
+        current.filterSamples { !range.contains($0) }
295
+        temperature.filterSamples { !range.contains($0) }
296
+        energy.filterSamples { !range.contains($0) }
297
+        rssi.filterSamples { !range.contains($0) }
298
+        lastEnergyCounterValue = nil
299
+        lastEnergyGroupID = nil
300
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
301
+        self.objectWillChange.send()
302
+    }
303
+
304
+    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double, rssi: Double) {
104 305
         let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue
105
-        if lastPointTimestamp == 0 {
106
-            lastPointTimestamp = valuesTimestamp
107
-        }
108
-        if lastPointTimestamp == valuesTimestamp {
306
+
307
+        if pendingBucketSecond == valuesTimestamp {
308
+            pendingBucketTimestamp = timestamp
109 309
             itemsInSum += 1
110 310
             powerSum += power
111 311
             voltageSum += voltage
112 312
             currentSum += current
313
+            temperatureSum += temperature
314
+            rssiSum += rssi
315
+            return
113 316
         }
114
-        else {
115
-            self.power.addPoint( timestamp: timestamp, value: powerSum / itemsInSum )
116
-            self.voltage.addPoint( timestamp: timestamp, value: voltageSum / itemsInSum )
117
-            self.current.addPoint( timestamp: timestamp, value: currentSum / itemsInSum )
118
-            lastPointTimestamp = valuesTimestamp
119
-            itemsInSum = 1
120
-            powerSum = power
121
-            voltageSum = voltage
122
-            currentSum = current
123
-            self.objectWillChange.send()
317
+
318
+        flushPendingValues()
319
+
320
+        pendingBucketSecond = valuesTimestamp
321
+        pendingBucketTimestamp = timestamp
322
+        itemsInSum = 1
323
+        powerSum = power
324
+        voltageSum = voltage
325
+        currentSum = current
326
+        temperatureSum = temperature
327
+        rssiSum = rssi
328
+    }
329
+
330
+    func markDiscontinuity(at timestamp: Date) {
331
+        flushPendingValues()
332
+        power.addDiscontinuity(timestamp: timestamp)
333
+        voltage.addDiscontinuity(timestamp: timestamp)
334
+        current.addDiscontinuity(timestamp: timestamp)
335
+        temperature.addDiscontinuity(timestamp: timestamp)
336
+        energy.addDiscontinuity(timestamp: timestamp)
337
+        rssi.addDiscontinuity(timestamp: timestamp)
338
+        self.objectWillChange.send()
339
+    }
340
+
341
+    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
342
+        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
343
+            let delta = value - lastEnergyCounterValue
344
+            if delta > energyResetEpsilon {
345
+                accumulatedEnergyValue += delta
346
+            }
347
+        }
348
+
349
+        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
350
+        lastEnergyCounterValue = value
351
+        lastEnergyGroupID = groupID
352
+        self.objectWillChange.send()
353
+    }
354
+
355
+    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
356
+        if shouldFlushPendingValues {
357
+            flushPendingValues()
124 358
         }
359
+        return power.samplePoints.count
360
+    }
361
+
362
+    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
363
+        if shouldFlushPendingValues {
364
+            flushPendingValues()
365
+        }
366
+
367
+        let samplePoints = power.samplePoints
368
+        guard limit > 0, samplePoints.count > limit else {
369
+            return samplePoints
370
+        }
371
+
372
+        return Array(samplePoints.suffix(limit))
373
+    }
374
+
375
+    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
376
+        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
377
+        guard !points.isEmpty else { return nil }
378
+
379
+        let sum = points.reduce(0) { partialResult, point in
380
+            partialResult + point.value
381
+        }
382
+
383
+        return sum / Double(points.count)
125 384
     }
126 385
 }
+68 -10
USB Meter/Model/Meter.swift
@@ -107,6 +107,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
107 107
                 break
108 108
             case .peripheralNotConnected:
109 109
                 cancelPendingDataDumpRequest(reason: "peripheral disconnected")
110
+                handleMeasurementDiscontinuity(at: Date())
110 111
                 if !commandQueue.isEmpty {
111 112
                     track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect")
112 113
                     commandQueue.removeAll()
@@ -152,9 +153,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
152 153
 
153 154
     private var wdTimer: Timer?
154 155
 
155
-    var lastSeen = Date() {
156
+    @Published var lastSeen: Date? {
156 157
         didSet {
157 158
             wdTimer?.invalidate()
159
+            guard lastSeen != nil else { return }
160
+            appData.noteMeterSeen(at: lastSeen!, macAddress: btSerial.macAddress.description)
158 161
             if operationalState == .peripheralNotConnected {
159 162
                 wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
160 163
                     track("\(self.name) - Lost advertisments...")
@@ -170,11 +173,19 @@ class Meter : NSObject, ObservableObject, Identifiable {
170 173
     var model: Model
171 174
     var modelString: String
172 175
     
173
-    var name: String {
176
+    private var isSyncingNameFromStore = false
177
+
178
+    @Published var name: String {
174 179
         didSet {
175
-            appData.meterNames[btSerial.macAddress.description] = name
180
+            guard !isSyncingNameFromStore else { return }
181
+            guard oldValue != name else { return }
182
+            appData.setMeterName(name, for: btSerial.macAddress.description)
176 183
         }
177 184
     }
185
+
186
+    var preferredTabIdentifier: String = "home"
187
+
188
+    @Published private(set) var lastConnectedAt: Date?
178 189
     
179 190
     var color : Color {
180 191
         get {
@@ -441,9 +452,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
441 452
         didSet {
442 453
             guard supportsManualTemperatureUnitSelection else { return }
443 454
             guard oldValue != tc66TemperatureUnitPreference else { return }
444
-            var settings = appData.tc66TemperatureUnits
445
-            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
446
-            appData.tc66TemperatureUnits = settings
455
+            appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description)
447 456
         }
448 457
     }
449 458
 
@@ -544,7 +553,9 @@ class Meter : NSObject, ObservableObject, Identifiable {
544 553
         modelString = serialPort.peripheral.name!
545 554
         self.model = model
546 555
         btSerial = serialPort
547
-        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
556
+        name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description
557
+        lastSeen = appData.lastSeen(for: serialPort.macAddress.description)
558
+        lastConnectedAt = appData.lastConnected(for: serialPort.macAddress.description)
548 559
         super.init()
549 560
         btSerial.delegate = self
550 561
         reloadTemperatureUnitPreference()
@@ -556,13 +567,45 @@ class Meter : NSObject, ObservableObject, Identifiable {
556 567
 
557 568
     func reloadTemperatureUnitPreference() {
558 569
         guard supportsManualTemperatureUnitSelection else { return }
559
-        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
560
-        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
570
+        let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description)
561 571
         if tc66TemperatureUnitPreference != persistedPreference {
562 572
             tc66TemperatureUnitPreference = persistedPreference
563 573
         }
564 574
     }
565 575
 
576
+    func updateNameFromStore(_ newName: String) {
577
+        guard newName != name else { return }
578
+        isSyncingNameFromStore = true
579
+        name = newName
580
+        isSyncingNameFromStore = false
581
+    }
582
+
583
+    private func noteConnectionEstablished(at date: Date) {
584
+        lastConnectedAt = date
585
+        appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description)
586
+    }
587
+
588
+    private func handleMeasurementDiscontinuity(at timestamp: Date) {
589
+        measurements.markDiscontinuity(at: timestamp)
590
+
591
+        guard chargeRecordState == .active else { return }
592
+        chargeRecordLastTimestamp = nil
593
+        chargeRecordLastCurrent = 0
594
+        chargeRecordLastPower = 0
595
+    }
596
+
597
+    private func currentEnergySample() -> (groupID: UInt8, value: Double)? {
598
+        guard showsDataGroupEnergy else { return nil }
599
+
600
+        if model == .TC66C && !hasObservedActiveDataGroup {
601
+            return nil
602
+        }
603
+
604
+        let groupID = selectedDataGroup
605
+        guard let record = dataGroupRecords[Int(groupID)] else { return nil }
606
+        return (groupID, record.wh)
607
+    }
608
+
566 609
     private func cancelPendingDataDumpRequest(reason: String) {
567 610
         guard let pendingDataDumpWorkItem else { return }
568 611
         track("\(name) - Cancel scheduled data request (\(reason))")
@@ -712,7 +755,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
712 755
             }
713 756
         }
714 757
         updateChargeRecord(at: dataDumpRequestTimestamp)
715
-        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
758
+        if let energySample = currentEnergySample() {
759
+            measurements.captureEnergyValue(
760
+                timestamp: dataDumpRequestTimestamp,
761
+                value: energySample.value,
762
+                groupID: energySample.groupID
763
+            )
764
+        }
765
+        measurements.addValues(
766
+            timestamp: dataDumpRequestTimestamp,
767
+            power: power,
768
+            voltage: voltage,
769
+            current: current,
770
+            temperature: displayedTemperatureValue,
771
+            rssi: Double(btSerial.averageRSSI)
772
+        )
716 773
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
717 774
 //            //track("\(name) - Scheduled new request.")
718 775
 //        }
@@ -966,6 +1023,7 @@ extension Meter : SerialPortDelegate {
966 1023
             case .peripheralConnectionPending:
967 1024
                 self.operationalState = .peripheralConnectionPending
968 1025
             case .peripheralConnected:
1026
+                self.noteConnectionEstablished(at: Date())
969 1027
                 self.operationalState = .peripheralConnected
970 1028
             case .peripheralReady:
971 1029
                 self.operationalState = .peripheralReady
+431 -0
USB Meter/Model/MeterNameStore.swift
@@ -0,0 +1,431 @@
1
+//  MeterNameStore.swift
2
+//  USB Meter
3
+//
4
+//  Created by Codex on 2026.
5
+//
6
+
7
+import Foundation
8
+
9
+final class MeterNameStore {
10
+    struct Record: Identifiable {
11
+        let macAddress: String
12
+        let customName: String?
13
+        let temperatureUnit: String?
14
+        let modelName: String?
15
+        let advertisedName: String?
16
+        let lastSeen: Date?
17
+        let lastConnected: Date?
18
+
19
+        var id: String {
20
+            macAddress
21
+        }
22
+    }
23
+
24
+    enum CloudAvailability: Equatable {
25
+        case unknown
26
+        case available
27
+        case noAccount
28
+        case error(String)
29
+
30
+        var helpTitle: String {
31
+            switch self {
32
+            case .unknown:
33
+                return "Cloud Sync Status Unknown"
34
+            case .available:
35
+                return "Cloud Sync Ready"
36
+            case .noAccount:
37
+                return "Enable iCloud Drive"
38
+            case .error:
39
+                return "Cloud Sync Error"
40
+            }
41
+        }
42
+
43
+        var helpMessage: String {
44
+            switch self {
45
+            case .unknown:
46
+                return "The app is still checking whether iCloud sync is available on this device."
47
+            case .available:
48
+                return "iCloud sync is available for meter names and TC66 temperature preferences."
49
+            case .noAccount:
50
+                return "Meter names and TC66 temperature preferences sync through iCloud Drive. The app keeps a local copy too, but cross-device sync stays off until iCloud Drive is available."
51
+            case .error(let description):
52
+                return "The app keeps local values, but iCloud sync reported an error: \(description)"
53
+            }
54
+        }
55
+    }
56
+
57
+    static let shared = MeterNameStore()
58
+
59
+    private enum Keys {
60
+        static let meters = "MeterNameStore.meters"
61
+        static let localMeterNames = "MeterNameStore.localMeterNames"
62
+        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
63
+        static let localModelNames = "MeterNameStore.localModelNames"
64
+        static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames"
65
+        static let localLastSeen = "MeterNameStore.localLastSeen"
66
+        static let localLastConnected = "MeterNameStore.localLastConnected"
67
+        static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
68
+        static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
69
+    }
70
+
71
+    private let defaults = UserDefaults.standard
72
+    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
73
+    private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
74
+    private var cloudAvailability: CloudAvailability = .unknown
75
+    private var ubiquitousObserver: NSObjectProtocol?
76
+    private var ubiquityIdentityObserver: NSObjectProtocol?
77
+
78
+    private init() {
79
+        ubiquitousObserver = NotificationCenter.default.addObserver(
80
+            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
81
+            object: ubiquitousStore,
82
+            queue: nil
83
+        ) { [weak self] notification in
84
+            self?.handleUbiquitousStoreChange(notification)
85
+        }
86
+
87
+        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
88
+            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
89
+            object: nil,
90
+            queue: nil
91
+        ) { [weak self] _ in
92
+            self?.refreshCloudAvailability(reason: "identity-changed")
93
+            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
94
+        }
95
+
96
+        refreshCloudAvailability(reason: "startup")
97
+        ubiquitousStore.synchronize()
98
+        syncLocalValuesToCloudIfPossible(reason: "startup")
99
+    }
100
+
101
+    var currentCloudAvailability: CloudAvailability {
102
+        workQueue.sync {
103
+            cloudAvailability
104
+        }
105
+    }
106
+
107
+    func name(for macAddress: String) -> String? {
108
+        let normalizedMAC = normalizedMACAddress(macAddress)
109
+        guard !normalizedMAC.isEmpty else { return nil }
110
+        return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC]
111
+    }
112
+
113
+    func temperatureUnitRawValue(for macAddress: String) -> String? {
114
+        let normalizedMAC = normalizedMACAddress(macAddress)
115
+        guard !normalizedMAC.isEmpty else { return nil }
116
+        return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
117
+    }
118
+
119
+    func lastSeen(for macAddress: String) -> Date? {
120
+        let normalizedMAC = normalizedMACAddress(macAddress)
121
+        guard !normalizedMAC.isEmpty else { return nil }
122
+        return dateDictionary(for: Keys.localLastSeen)[normalizedMAC]
123
+    }
124
+
125
+    func lastConnected(for macAddress: String) -> Date? {
126
+        let normalizedMAC = normalizedMACAddress(macAddress)
127
+        guard !normalizedMAC.isEmpty else { return nil }
128
+        return dateDictionary(for: Keys.localLastConnected)[normalizedMAC]
129
+    }
130
+
131
+    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
132
+        let normalizedMAC = normalizedMACAddress(macAddress)
133
+        guard !normalizedMAC.isEmpty else {
134
+            track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
135
+            return
136
+        }
137
+
138
+        var didChange = false
139
+        didChange = updateMetersSet(normalizedMAC) || didChange
140
+        didChange = updateDictionaryValue(
141
+            for: normalizedMAC,
142
+            value: normalizedName(modelName),
143
+            localKey: Keys.localModelNames,
144
+            cloudKey: nil
145
+        ) || didChange
146
+        didChange = updateDictionaryValue(
147
+            for: normalizedMAC,
148
+            value: normalizedName(advertisedName),
149
+            localKey: Keys.localAdvertisedNames,
150
+            cloudKey: nil
151
+        ) || didChange
152
+
153
+        if didChange {
154
+            notifyChange()
155
+        }
156
+    }
157
+
158
+    func noteLastSeen(_ date: Date, for macAddress: String) {
159
+        updateDate(date, for: macAddress, key: Keys.localLastSeen)
160
+    }
161
+
162
+    func noteLastConnected(_ date: Date, for macAddress: String) {
163
+        updateDate(date, for: macAddress, key: Keys.localLastConnected)
164
+    }
165
+
166
+    func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
167
+        let normalizedMAC = normalizedMACAddress(macAddress)
168
+        guard !normalizedMAC.isEmpty else {
169
+            track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
170
+            return
171
+        }
172
+
173
+        var didChange = false
174
+        didChange = updateMetersSet(normalizedMAC) || didChange
175
+
176
+        if let name {
177
+            didChange = updateDictionaryValue(
178
+                for: normalizedMAC,
179
+                value: normalizedName(name),
180
+                localKey: Keys.localMeterNames,
181
+                cloudKey: Keys.cloudMeterNames
182
+            ) || didChange
183
+        }
184
+
185
+        if let temperatureUnitRawValue {
186
+            didChange = updateDictionaryValue(
187
+                for: normalizedMAC,
188
+                value: normalizedTemperatureUnit(temperatureUnitRawValue),
189
+                localKey: Keys.localTemperatureUnits,
190
+                cloudKey: Keys.cloudTemperatureUnits
191
+            ) || didChange
192
+        }
193
+
194
+        if didChange {
195
+            notifyChange()
196
+        }
197
+    }
198
+
199
+    func allRecords() -> [Record] {
200
+        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
201
+        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
202
+        let modelNames = dictionary(for: Keys.localModelNames, store: defaults)
203
+        let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
204
+        let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
205
+        let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
206
+        let macAddresses = meters()
207
+            .union(names.keys)
208
+            .union(temperatureUnits.keys)
209
+            .union(modelNames.keys)
210
+            .union(advertisedNames.keys)
211
+            .union(lastSeenValues.keys)
212
+            .union(lastConnectedValues.keys)
213
+
214
+        return macAddresses.sorted().map { macAddress in
215
+            Record(
216
+                macAddress: macAddress,
217
+                customName: names[macAddress],
218
+                temperatureUnit: temperatureUnits[macAddress],
219
+                modelName: modelNames[macAddress],
220
+                advertisedName: advertisedNames[macAddress],
221
+                lastSeen: lastSeenValues[macAddress],
222
+                lastConnected: lastConnectedValues[macAddress]
223
+            )
224
+        }
225
+    }
226
+
227
+    private func normalizedMACAddress(_ macAddress: String) -> String {
228
+        macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
229
+    }
230
+
231
+    private func normalizedName(_ name: String?) -> String? {
232
+        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
233
+              !trimmed.isEmpty else {
234
+            return nil
235
+        }
236
+        return trimmed
237
+    }
238
+
239
+    private func normalizedTemperatureUnit(_ value: String?) -> String? {
240
+        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
241
+              !trimmed.isEmpty else {
242
+            return nil
243
+        }
244
+        return trimmed
245
+    }
246
+
247
+    private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
248
+        (store.object(forKey: key) as? [String: String]) ?? [:]
249
+    }
250
+
251
+    private func dateDictionary(for key: String) -> [String: Date] {
252
+        let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
253
+        return rawValues.mapValues(Date.init(timeIntervalSince1970:))
254
+    }
255
+
256
+    private func meters() -> Set<String> {
257
+        Set((defaults.array(forKey: Keys.meters) as? [String]) ?? [])
258
+    }
259
+
260
+    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
261
+        let localValues = dictionary(for: localKey, store: defaults)
262
+        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
263
+        return localValues.merging(cloudValues) { _, cloudValue in
264
+            cloudValue
265
+        }
266
+    }
267
+
268
+    @discardableResult
269
+    private func updateMetersSet(_ macAddress: String) -> Bool {
270
+        var known = meters()
271
+        let initialCount = known.count
272
+        known.insert(macAddress)
273
+        guard known.count != initialCount else { return false }
274
+        defaults.set(Array(known).sorted(), forKey: Keys.meters)
275
+        return true
276
+    }
277
+
278
+    @discardableResult
279
+    private func updateDictionaryValue(
280
+        for macAddress: String,
281
+        value: String?,
282
+        localKey: String,
283
+        cloudKey: String?
284
+    ) -> Bool {
285
+        var localValues = dictionary(for: localKey, store: defaults)
286
+        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
287
+        if didChangeLocal {
288
+            defaults.set(localValues, forKey: localKey)
289
+        }
290
+
291
+        var didChangeCloud = false
292
+        if let cloudKey, isICloudDriveAvailable {
293
+            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
294
+            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
295
+            if didChangeCloud {
296
+                ubiquitousStore.set(cloudValues, forKey: cloudKey)
297
+                ubiquitousStore.synchronize()
298
+            }
299
+        }
300
+
301
+        return didChangeLocal || didChangeCloud
302
+    }
303
+
304
+    @discardableResult
305
+    private func setDictionaryValue(
306
+        _ dictionary: inout [String: String],
307
+        for macAddress: String,
308
+        value: String?
309
+    ) -> Bool {
310
+        let currentValue = dictionary[macAddress]
311
+        guard currentValue != value else { return false }
312
+        if let value {
313
+            dictionary[macAddress] = value
314
+        } else {
315
+            dictionary.removeValue(forKey: macAddress)
316
+        }
317
+        return true
318
+    }
319
+
320
+    private func updateDate(_ date: Date, for macAddress: String, key: String) {
321
+        let normalizedMAC = normalizedMACAddress(macAddress)
322
+        guard !normalizedMAC.isEmpty else {
323
+            track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
324
+            return
325
+        }
326
+
327
+        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
328
+        let timeInterval = date.timeIntervalSince1970
329
+        guard values[normalizedMAC] != timeInterval else { return }
330
+        values[normalizedMAC] = timeInterval
331
+        defaults.set(values, forKey: key)
332
+        _ = updateMetersSet(normalizedMAC)
333
+        notifyChange()
334
+    }
335
+
336
+    private var isICloudDriveAvailable: Bool {
337
+        FileManager.default.ubiquityIdentityToken != nil
338
+    }
339
+
340
+    private func refreshCloudAvailability(reason: String) {
341
+        let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
342
+
343
+        var shouldNotify = false
344
+        workQueue.sync {
345
+            guard cloudAvailability != newAvailability else { return }
346
+            cloudAvailability = newAvailability
347
+            shouldNotify = true
348
+        }
349
+
350
+        guard shouldNotify else { return }
351
+        track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
352
+        DispatchQueue.main.async {
353
+            NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
354
+        }
355
+    }
356
+
357
+    private func handleUbiquitousStoreChange(_ notification: Notification) {
358
+        refreshCloudAvailability(reason: "ubiquitous-store-change")
359
+        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
360
+            track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
361
+        }
362
+        notifyChange()
363
+    }
364
+
365
+    private func syncLocalValuesToCloudIfPossible(reason: String) {
366
+        guard isICloudDriveAvailable else {
367
+            refreshCloudAvailability(reason: reason)
368
+            return
369
+        }
370
+
371
+        let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
372
+        let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
373
+
374
+        var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
375
+        var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
376
+
377
+        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
378
+            cloudValue
379
+        }
380
+        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
381
+            cloudValue
382
+        }
383
+
384
+        var didChange = false
385
+        if cloudNames != mergedNames {
386
+            cloudNames = mergedNames
387
+            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
388
+            didChange = true
389
+        }
390
+        if cloudTemperatureUnits != mergedTemperatureUnits {
391
+            cloudTemperatureUnits = mergedTemperatureUnits
392
+            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
393
+            didChange = true
394
+        }
395
+
396
+        refreshCloudAvailability(reason: reason)
397
+
398
+        if didChange {
399
+            ubiquitousStore.synchronize()
400
+            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
401
+            notifyChange()
402
+        }
403
+    }
404
+
405
+    private func notifyChange() {
406
+        DispatchQueue.main.async {
407
+            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
408
+        }
409
+    }
410
+
411
+    deinit {
412
+        if let observer = ubiquitousObserver {
413
+            NotificationCenter.default.removeObserver(observer)
414
+        }
415
+        if let observer = ubiquityIdentityObserver {
416
+            NotificationCenter.default.removeObserver(observer)
417
+        }
418
+    }
419
+}
420
+
421
+private protocol KeyValueReading {
422
+    func object(forKey defaultName: String) -> Any?
423
+}
424
+
425
+extension UserDefaults: KeyValueReading {}
426
+extension NSUbiquitousKeyValueStore: KeyValueReading {}
427
+
428
+extension Notification.Name {
429
+    static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
430
+    static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
431
+}
+0 -9
USB Meter/SceneDelegate.swift
@@ -20,13 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
20 20
         // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
21 21
         // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
22 22
         
23
-        // Get the managed object context from the shared persistent container.
24
-        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
25
-        
26
-        // Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
27
-        // Add `@Environment(\.managedObjectContext)` in the views that will need the context.
28 23
         let contentView = ContentView()
29
-            .environment(\.managedObjectContext, context)
30 24
             .environmentObject(appData)        
31 25
         
32 26
         // Use a UIHostingController as window root view controller.
@@ -65,10 +59,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
65 59
         // Use this method to save data, release shared resources, and store enough scene-specific state information
66 60
         // to restore the scene back to its current state.
67 61
         
68
-        // Save changes in the application's managed object context when the application transitions to the background.
69
-        (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
70 62
     }
71 63
     
72 64
     
73 65
 }
74
-
+0 -29
USB Meter/Templates/ICloudDefault.swift
@@ -1,29 +0,0 @@
1
-//
2
-//  ICloudDefault.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 12/04/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import Foundation
10
-// https://github.com/lobodpav/Xcode11.4Issues/blob/master/Sources/Xcode11.4Test/CloudListener.swift
11
-// https://medium.com/@craiggrummitt/boss-level-property-wrappers-and-user-defaults-6a28c7527cf
12
-@propertyWrapper struct ICloudDefault<T> {
13
-  let key: String
14
-  let defaultValue: T
15
- 
16
-  var wrappedValue: T {
17
-    get {
18
-        return NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
19
-    }
20
-    set {
21
-        NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
22
-        /* MARK: Sincronizarea forțată
23
-            Face ca sincronizarea intre dispozitive mai repidă dar există o limita de update-uri catre iloud
24
-        */
25
-        NSUbiquitousKeyValueStore.default.synchronize()
26
-        track("Pushed into iCloud value: '\(newValue)' for key: '\(key)'")
27
-    }
28
-  }
29
-}
+0 -2
USB Meter/USB Meter.entitlements
@@ -2,8 +2,6 @@
2 2
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 3
 <plist version="1.0">
4 4
 <dict>
5
-	<key>com.apple.developer.icloud-container-identifiers</key>
6
-	<array/>
7 5
 	<key>com.apple.developer.ubiquity-kvstore-identifier</key>
8 6
 	<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
9 7
 </dict>
+0 -32
USB Meter/Views/BorderView.swift
@@ -1,32 +0,0 @@
1
-//
2
-//  BorderView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 11/04/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import SwiftUI
10
-
11
-struct BorderView: View {
12
-    let show: Bool
13
-    var fillColor: Color = .clear
14
-    var opacity = 0.5
15
-    
16
-    var body: some View {
17
-        ZStack {
18
-        RoundedRectangle(cornerRadius: 10)
19
-            .foregroundColor(fillColor).opacity(opacity)
20
-
21
-        RoundedRectangle(cornerRadius: 10)
22
-            .stroke(lineWidth: 3.0).foregroundColor(show ? fillColor : Color.clear)
23
-            .animation(.linear(duration: 0.1), value: show)
24
-        }
25
-    }
26
-}
27
-
28
-struct BorderView_Previews: PreviewProvider {
29
-    static var previews: some View {
30
-        BorderView(show: true)
31
-    }
32
-}
+1 -1
USB Meter/Views/Meter/ChevronView.swift → USB Meter/Views/Components/Generic/ChevronView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  SwiftUIView.swift
2
+//  ChevronView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 02/05/2020.
+2 -2
USB Meter/Views/Meter/RSSIView.swift → USB Meter/Views/Components/Generic/RSSIView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  SwiftUIView.swift
2
+//  RSSIView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 14/03/2020.
@@ -54,7 +54,7 @@ struct RSSIView: View {
54 54
 }
55 55
 
56 56
 
57
-struct SwiftUIView_Previews: PreviewProvider {
57
+struct RSSIView_Previews: PreviewProvider {
58 58
     static var previews: some View {
59 59
         RSSIView(RSSI: -80).frame(width: 20, height: 20, alignment: .center)
60 60
     }
+2 -374
USB Meter/Views/ContentView.swift
@@ -9,384 +9,12 @@
9 9
 //MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg
10 10
 
11 11
 import SwiftUI
12
-import Combine
13 12
 
14 13
 struct ContentView: View {
15
-    private enum HelpAutoReason: String {
16
-        case bluetoothPermission
17
-        case noDevicesDetected
18
-
19
-        var tint: Color {
20
-            switch self {
21
-            case .bluetoothPermission:
22
-                return .orange
23
-            case .noDevicesDetected:
24
-                return .yellow
25
-            }
26
-        }
27
-
28
-        var symbol: String {
29
-            switch self {
30
-            case .bluetoothPermission:
31
-                return "bolt.horizontal.circle.fill"
32
-            case .noDevicesDetected:
33
-                return "magnifyingglass.circle.fill"
34
-            }
35
-        }
36
-
37
-        var badgeTitle: String {
38
-            switch self {
39
-            case .bluetoothPermission:
40
-                return "Required"
41
-            case .noDevicesDetected:
42
-                return "Suggested"
43
-            }
44
-        }
45
-    }
46
-    
47
-    @EnvironmentObject private var appData: AppData
48
-    @State private var isHelpExpanded = false
49
-    @State private var dismissedAutoHelpReason: HelpAutoReason?
50
-    @State private var now = Date()
51
-    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
52
-    private let noDevicesHelpDelay: TimeInterval = 12
53
-    
54 14
     var body: some View {
55 15
         NavigationView {
56
-            ScrollView {
57
-                VStack(alignment: .leading, spacing: 18) {
58
-                    headerCard
59
-                    helpSection
60
-                    devicesSection
61
-                }
62
-                .padding()
63
-            }
64
-            .background(
65
-                LinearGradient(
66
-                    colors: [
67
-                        appData.bluetoothManager.managerState.color.opacity(0.18),
68
-                        Color.clear
69
-                    ],
70
-                    startPoint: .topLeading,
71
-                    endPoint: .bottomTrailing
72
-                )
73
-                .ignoresSafeArea()
74
-            )
75
-            .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
76
-        }
77
-        .onAppear {
78
-            appData.bluetoothManager.start()
79
-            now = Date()
80
-        }
81
-        .onReceive(helpRefreshTimer) { currentDate in
82
-            now = currentDate
83
-        }
84
-        .onChange(of: activeHelpAutoReason) { newReason in
85
-            if newReason == nil {
86
-                dismissedAutoHelpReason = nil
87
-            }
88
-        }
89
-    }
90
-
91
-    private var headerCard: some View {
92
-        VStack(alignment: .leading, spacing: 10) {
93
-            Text("USB Meters")
94
-                .font(.system(.title2, design: .rounded).weight(.bold))
95
-            Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.")
96
-                .font(.footnote)
97
-                .foregroundColor(.secondary)
98
-            HStack {
99
-                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
100
-                    .font(.footnote.weight(.semibold))
101
-                    .foregroundColor(appData.bluetoothManager.managerState.color)
102
-                Spacer()
103
-                Text(bluetoothStatusText)
104
-                    .font(.caption.weight(.semibold))
105
-                    .foregroundColor(.secondary)
106
-            }
107
-        }
108
-        .padding(18)
109
-        .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26)
110
-    }
111
-
112
-    private var helpSection: some View {
113
-        VStack(alignment: .leading, spacing: 12) {
114
-            Button(action: toggleHelpSection) {
115
-                HStack(spacing: 14) {
116
-                    Image(systemName: helpSectionSymbol)
117
-                        .font(.system(size: 18, weight: .semibold))
118
-                        .foregroundColor(helpSectionTint)
119
-                        .frame(width: 42, height: 42)
120
-                        .background(Circle().fill(helpSectionTint.opacity(0.18)))
121
-
122
-                    VStack(alignment: .leading, spacing: 4) {
123
-                        Text("Help")
124
-                            .font(.headline)
125
-                        Text(helpSectionSummary)
126
-                            .font(.caption)
127
-                            .foregroundColor(.secondary)
128
-                    }
129
-
130
-                    Spacer()
131
-
132
-                    if let activeHelpAutoReason {
133
-                        Text(activeHelpAutoReason.badgeTitle)
134
-                            .font(.caption2.weight(.bold))
135
-                            .foregroundColor(activeHelpAutoReason.tint)
136
-                            .padding(.horizontal, 10)
137
-                            .padding(.vertical, 6)
138
-                            .background(
139
-                                Capsule(style: .continuous)
140
-                                    .fill(activeHelpAutoReason.tint.opacity(0.12))
141
-                            )
142
-                            .overlay(
143
-                                Capsule(style: .continuous)
144
-                                    .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1)
145
-                            )
146
-                    }
147
-
148
-                    Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down")
149
-                        .font(.footnote.weight(.bold))
150
-                        .foregroundColor(.secondary)
151
-                }
152
-                .padding(14)
153
-                .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
154
-            }
155
-            .buttonStyle(.plain)
156
-
157
-            if helpIsExpanded {
158
-                if let activeHelpAutoReason {
159
-                    helpNoticeCard(for: activeHelpAutoReason)
160
-                }
161
-
162
-                NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
163
-                    sidebarLinkCard(
164
-                        title: "Bluetooth",
165
-                        subtitle: "Permissions, adapter state, and connection tips.",
166
-                        symbol: "bolt.horizontal.circle.fill",
167
-                        tint: appData.bluetoothManager.managerState.color
168
-                    )
169
-                }
170
-                .buttonStyle(.plain)
171
-
172
-                NavigationLink(destination: DeviceHelpView()) {
173
-                    sidebarLinkCard(
174
-                        title: "Device",
175
-                        subtitle: "Quick checks when a meter is not responding as expected.",
176
-                        symbol: "questionmark.circle.fill",
177
-                        tint: .orange
178
-                    )
179
-                }
180
-                .buttonStyle(.plain)
181
-            }
182
-        }
183
-        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
184
-    }
185
-
186
-    private var devicesSection: some View {
187
-        VStack(alignment: .leading, spacing: 12) {
188
-            HStack {
189
-                Text("Discovered Devices")
190
-                    .font(.headline)
191
-                Spacer()
192
-                Text("\(appData.meters.count)")
193
-                    .font(.caption.weight(.bold))
194
-                    .padding(.horizontal, 10)
195
-                    .padding(.vertical, 6)
196
-                    .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
197
-            }
198
-
199
-            if appData.meters.isEmpty {
200
-                Text(devicesEmptyStateText)
201
-                    .font(.footnote)
202
-                    .foregroundColor(.secondary)
203
-                    .frame(maxWidth: .infinity, alignment: .leading)
204
-                    .padding(18)
205
-                    .meterCard(
206
-                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
207
-                        fillOpacity: 0.14,
208
-                        strokeOpacity: 0.20
209
-                    )
210
-            } else {
211
-                ForEach(discoveredMeters, id: \.self) { meter in
212
-                    NavigationLink(destination: MeterView().environmentObject(meter)) {
213
-                        MeterRowView()
214
-                            .environmentObject(meter)
215
-                    }
216
-                    .buttonStyle(.plain)
217
-                }
218
-            }
219
-        }
220
-    }
221
-
222
-    private var discoveredMeters: [Meter] {
223
-        Array(appData.meters.values).sorted { lhs, rhs in
224
-            lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
225
-        }
226
-    }
227
-
228
-    private var bluetoothStatusText: String {
229
-        switch appData.bluetoothManager.managerState {
230
-        case .poweredOff:
231
-            return "Off"
232
-        case .poweredOn:
233
-            return "On"
234
-        case .resetting:
235
-            return "Resetting"
236
-        case .unauthorized:
237
-            return "Unauthorized"
238
-        case .unknown:
239
-            return "Unknown"
240
-        case .unsupported:
241
-            return "Unsupported"
242
-        @unknown default:
243
-            return "Other"
244
-        }
245
-    }
246
-
247
-    private var helpIsExpanded: Bool {
248
-        isHelpExpanded || shouldAutoExpandHelp
249
-    }
250
-
251
-    private var shouldAutoExpandHelp: Bool {
252
-        guard let activeHelpAutoReason else {
253
-            return false
254
-        }
255
-        return dismissedAutoHelpReason != activeHelpAutoReason
256
-    }
257
-
258
-    private var activeHelpAutoReason: HelpAutoReason? {
259
-        if appData.bluetoothManager.managerState == .unauthorized {
260
-            return .bluetoothPermission
261
-        }
262
-        if hasWaitedLongEnoughForDevices {
263
-            return .noDevicesDetected
264
-        }
265
-        return nil
266
-    }
267
-
268
-    private var hasWaitedLongEnoughForDevices: Bool {
269
-        guard appData.bluetoothManager.managerState == .poweredOn else {
270
-            return false
271
-        }
272
-        guard appData.meters.isEmpty else {
273
-            return false
274
-        }
275
-        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
276
-            return false
277
-        }
278
-        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
279
-    }
280
-
281
-    private var isWaitingForFirstDiscovery: Bool {
282
-        guard appData.bluetoothManager.managerState == .poweredOn else {
283
-            return false
284
-        }
285
-        guard appData.meters.isEmpty else {
286
-            return false
287
-        }
288
-        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
289
-            return false
290
-        }
291
-        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
292
-    }
293
-
294
-    private var devicesEmptyStateText: String {
295
-        if isWaitingForFirstDiscovery {
296
-            return "Scanning for nearby supported meters..."
297
-        }
298
-        return "No supported meters are visible right now."
299
-    }
300
-
301
-    private var helpSectionTint: Color {
302
-        activeHelpAutoReason?.tint ?? .secondary
303
-    }
304
-
305
-    private var helpSectionSymbol: String {
306
-        activeHelpAutoReason?.symbol ?? "questionmark.circle.fill"
307
-    }
308
-
309
-    private var helpSectionSummary: String {
310
-        switch activeHelpAutoReason {
311
-        case .bluetoothPermission:
312
-            return "Bluetooth permission is needed before scanning can begin."
313
-        case .noDevicesDetected:
314
-            return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
315
-        case nil:
316
-            return "Connection tips and quick checks when discovery needs help."
317
-        }
318
-    }
319
-
320
-    private func toggleHelpSection() {
321
-        withAnimation(.easeInOut(duration: 0.22)) {
322
-            if shouldAutoExpandHelp {
323
-                dismissedAutoHelpReason = activeHelpAutoReason
324
-                isHelpExpanded = false
325
-            } else {
326
-                isHelpExpanded.toggle()
327
-            }
328
-        }
329
-    }
330
-
331
-    private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
332
-        VStack(alignment: .leading, spacing: 8) {
333
-            Text(helpNoticeTitle(for: reason))
334
-                .font(.subheadline.weight(.semibold))
335
-            Text(helpNoticeDetail(for: reason))
336
-                .font(.caption)
337
-                .foregroundColor(.secondary)
338
-        }
339
-        .frame(maxWidth: .infinity, alignment: .leading)
340
-        .padding(14)
341
-        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
342
-    }
343
-
344
-    private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
345
-        switch reason {
346
-        case .bluetoothPermission:
347
-            return "Bluetooth access needs attention"
348
-        case .noDevicesDetected:
349
-            return "No supported meters found yet"
350
-        }
351
-    }
352
-
353
-    private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
354
-        switch reason {
355
-        case .bluetoothPermission:
356
-            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
357
-        case .noDevicesDetected:
358
-            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
359
-        }
360
-    }
361
-
362
-    private func sidebarLinkCard(
363
-        title: String,
364
-        subtitle: String,
365
-        symbol: String,
366
-        tint: Color
367
-    ) -> some View {
368
-        HStack(spacing: 14) {
369
-            Image(systemName: symbol)
370
-                .font(.system(size: 18, weight: .semibold))
371
-                .foregroundColor(tint)
372
-                .frame(width: 42, height: 42)
373
-                .background(Circle().fill(tint.opacity(0.18)))
374
-
375
-            VStack(alignment: .leading, spacing: 4) {
376
-                Text(title)
377
-                    .font(.headline)
378
-                Text(subtitle)
379
-                    .font(.caption)
380
-                    .foregroundColor(.secondary)
381
-            }
382
-
383
-            Spacer()
384
-
385
-            Image(systemName: "chevron.right")
386
-                .font(.footnote.weight(.bold))
387
-                .foregroundColor(.secondary)
16
+            SidebarView()
17
+                .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
388 18
         }
389
-        .padding(14)
390
-        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
391 19
     }
392 20
 }
+2780 -0
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -0,0 +1,2780 @@
1
+//
2
+//  MeasurementChartView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 06/05/2020.
6
+//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
+//
8
+
9
+import SwiftUI
10
+
11
+private enum PresentTrackingMode: CaseIterable, Hashable {
12
+    case keepDuration
13
+    case keepStartTimestamp
14
+}
15
+
16
+struct MeasurementChartView: View {
17
+    private enum SmoothingLevel: CaseIterable, Hashable {
18
+        case off
19
+        case light
20
+        case medium
21
+        case strong
22
+
23
+        var label: String {
24
+            switch self {
25
+            case .off: return "Off"
26
+            case .light: return "Light"
27
+            case .medium: return "Medium"
28
+            case .strong: return "Strong"
29
+            }
30
+        }
31
+
32
+        var shortLabel: String {
33
+            switch self {
34
+            case .off: return "Off"
35
+            case .light: return "Low"
36
+            case .medium: return "Med"
37
+            case .strong: return "High"
38
+            }
39
+        }
40
+
41
+        var movingAverageWindowSize: Int {
42
+            switch self {
43
+            case .off: return 1
44
+            case .light: return 5
45
+            case .medium: return 11
46
+            case .strong: return 21
47
+            }
48
+        }
49
+    }
50
+
51
+    private enum SeriesKind {
52
+        case power
53
+        case energy
54
+        case voltage
55
+        case current
56
+        case temperature
57
+
58
+        var unit: String {
59
+            switch self {
60
+            case .power: return "W"
61
+            case .energy: return "Wh"
62
+            case .voltage: return "V"
63
+            case .current: return "A"
64
+            case .temperature: return ""
65
+            }
66
+        }
67
+
68
+        var tint: Color {
69
+            switch self {
70
+            case .power: return .red
71
+            case .energy: return .teal
72
+            case .voltage: return .green
73
+            case .current: return .blue
74
+            case .temperature: return .orange
75
+            }
76
+        }
77
+    }
78
+
79
+    private struct SeriesData {
80
+        let kind: SeriesKind
81
+        let points: [Measurements.Measurement.Point]
82
+        let samplePoints: [Measurements.Measurement.Point]
83
+        let context: ChartContext
84
+        let autoLowerBound: Double
85
+        let autoUpperBound: Double
86
+        let maximumSampleValue: Double?
87
+    }
88
+
89
+    private let minimumTimeSpan: TimeInterval = 1
90
+    private let minimumVoltageSpan = 0.5
91
+    private let minimumCurrentSpan = 0.5
92
+    private let minimumPowerSpan = 0.5
93
+    private let minimumEnergySpan = 0.1
94
+    private let minimumTemperatureSpan = 1.0
95
+    private let defaultEmptyChartTimeSpan: TimeInterval = 60
96
+    private let selectorTint: Color = .blue
97
+
98
+    let compactLayout: Bool
99
+    let availableSize: CGSize
100
+    
101
+    @EnvironmentObject private var measurements: Measurements
102
+    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
103
+    @Environment(\.verticalSizeClass) private var verticalSizeClass
104
+    var timeRange: ClosedRange<Date>? = nil
105
+    
106
+    @State var displayVoltage: Bool = false
107
+    @State var displayCurrent: Bool = false
108
+    @State var displayPower: Bool = true
109
+    @State var displayEnergy: Bool = false
110
+    @State var displayTemperature: Bool = false
111
+    @State private var smoothingLevel: SmoothingLevel = .off
112
+    @State private var chartNow: Date = Date()
113
+    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
114
+    @State private var isPinnedToPresent: Bool = false
115
+    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
116
+    @State private var pinOrigin: Bool = false
117
+    @State private var useSharedOrigin: Bool = false
118
+    @State private var sharedAxisOrigin: Double = 0
119
+    @State private var sharedAxisUpperBound: Double = 1
120
+    @State private var powerAxisOrigin: Double = 0
121
+    @State private var energyAxisOrigin: Double = 0
122
+    @State private var voltageAxisOrigin: Double = 0
123
+    @State private var currentAxisOrigin: Double = 0
124
+    @State private var temperatureAxisOrigin: Double = 0
125
+    let xLabels: Int = 4
126
+    let yLabels: Int = 4
127
+
128
+    init(
129
+        compactLayout: Bool = false,
130
+        availableSize: CGSize = .zero,
131
+        timeRange: ClosedRange<Date>? = nil
132
+    ) {
133
+        self.compactLayout = compactLayout
134
+        self.availableSize = availableSize
135
+        self.timeRange = timeRange
136
+    }
137
+
138
+    private var axisColumnWidth: CGFloat {
139
+        if compactLayout {
140
+            return 38
141
+        }
142
+        return isLargeDisplay ? 62 : 46
143
+    }
144
+
145
+    private var chartSectionSpacing: CGFloat {
146
+        compactLayout ? 6 : 8
147
+    }
148
+
149
+    private var xAxisHeight: CGFloat {
150
+        if compactLayout {
151
+            return 24
152
+        }
153
+        return isLargeDisplay ? 36 : 28
154
+    }
155
+
156
+    private var isPortraitLayout: Bool {
157
+        guard availableSize != .zero else { return verticalSizeClass != .compact }
158
+        return availableSize.height >= availableSize.width
159
+    }
160
+
161
+    private var isIPhone: Bool {
162
+        #if os(iOS)
163
+        return UIDevice.current.userInterfaceIdiom == .phone
164
+        #else
165
+        return false
166
+        #endif
167
+    }
168
+
169
+    private enum OriginControlsPlacement {
170
+        case aboveXAxisLegend
171
+        case overXAxisLegend
172
+        case belowXAxisLegend
173
+    }
174
+
175
+    private var originControlsPlacement: OriginControlsPlacement {
176
+        if isIPhone {
177
+            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
178
+        }
179
+        return .belowXAxisLegend
180
+    }
181
+
182
+    private var plotSectionHeight: CGFloat {
183
+        if availableSize == .zero {
184
+            return compactLayout ? 300 : 380
185
+        }
186
+
187
+        if isPortraitLayout {
188
+            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
189
+            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
190
+            return minimumPlotHeight + xAxisHeight
191
+        }
192
+
193
+        if compactLayout {
194
+            return min(max(availableSize.height * 0.36, 240), 300)
195
+        }
196
+
197
+        return min(max(availableSize.height * 0.5, 300), 440)
198
+    }
199
+
200
+    private var stackedToolbarLayout: Bool {
201
+        if availableSize.width > 0 {
202
+            return availableSize.width < 640
203
+        }
204
+
205
+        return horizontalSizeClass == .compact && verticalSizeClass != .compact
206
+    }
207
+
208
+    private var showsLabeledOriginControls: Bool {
209
+        !compactLayout && !stackedToolbarLayout
210
+    }
211
+
212
+    private var isLargeDisplay: Bool {
213
+        #if os(iOS)
214
+        if UIDevice.current.userInterfaceIdiom == .phone {
215
+            return false
216
+        }
217
+        #endif
218
+
219
+        if availableSize.width > 0 {
220
+            return availableSize.width >= 900 || availableSize.height >= 700
221
+        }
222
+        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
223
+    }
224
+
225
+    private var chartBaseFont: Font {
226
+        if isIPhone && isPortraitLayout {
227
+            return .caption
228
+        }
229
+        return isLargeDisplay ? .callout : .footnote
230
+    }
231
+
232
+    private var usesCompactLandscapeOriginControls: Bool {
233
+        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
234
+    }
235
+
236
+    var body: some View {
237
+        let availableTimeRange = availableSelectionTimeRange()
238
+        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
239
+        let powerSeries = series(
240
+            for: measurements.power,
241
+            kind: .power,
242
+            minimumYSpan: minimumPowerSpan,
243
+            visibleTimeRange: visibleTimeRange
244
+        )
245
+        let energySeries = series(
246
+            for: measurements.energy,
247
+            kind: .energy,
248
+            minimumYSpan: minimumEnergySpan,
249
+            visibleTimeRange: visibleTimeRange
250
+        )
251
+        let voltageSeries = series(
252
+            for: measurements.voltage,
253
+            kind: .voltage,
254
+            minimumYSpan: minimumVoltageSpan,
255
+            visibleTimeRange: visibleTimeRange
256
+        )
257
+        let currentSeries = series(
258
+            for: measurements.current,
259
+            kind: .current,
260
+            minimumYSpan: minimumCurrentSpan,
261
+            visibleTimeRange: visibleTimeRange
262
+        )
263
+        let temperatureSeries = series(
264
+            for: measurements.temperature,
265
+            kind: .temperature,
266
+            minimumYSpan: minimumTemperatureSpan,
267
+            visibleTimeRange: visibleTimeRange
268
+        )
269
+        let primarySeries = displayedPrimarySeries(
270
+            powerSeries: powerSeries,
271
+            energySeries: energySeries,
272
+            voltageSeries: voltageSeries,
273
+            currentSeries: currentSeries
274
+        )
275
+        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
276
+
277
+        Group {
278
+            if let primarySeries {
279
+                VStack(alignment: .leading, spacing: 12) {
280
+                    chartToggleBar()
281
+
282
+                    GeometryReader { geometry in
283
+                        let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220)
284
+
285
+                        VStack(spacing: 6) {
286
+                            HStack(spacing: chartSectionSpacing) {
287
+                                primaryAxisView(
288
+                                    height: plotHeight,
289
+                                    powerSeries: powerSeries,
290
+                                    energySeries: energySeries,
291
+                                    voltageSeries: voltageSeries,
292
+                                    currentSeries: currentSeries
293
+                                )
294
+                                .frame(width: axisColumnWidth, height: plotHeight)
295
+
296
+                                ZStack {
297
+                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
298
+                                        .fill(Color.primary.opacity(0.05))
299
+
300
+                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
301
+                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
302
+
303
+                                    horizontalGuides(context: primarySeries.context)
304
+                                    verticalGuides(context: primarySeries.context)
305
+                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
306
+                                    renderedChart(
307
+                                        powerSeries: powerSeries,
308
+                                        energySeries: energySeries,
309
+                                        voltageSeries: voltageSeries,
310
+                                        currentSeries: currentSeries,
311
+                                        temperatureSeries: temperatureSeries
312
+                                    )
313
+                                }
314
+                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
315
+                                .frame(maxWidth: .infinity)
316
+                                .frame(height: plotHeight)
317
+
318
+                                secondaryAxisView(
319
+                                    height: plotHeight,
320
+                                    powerSeries: powerSeries,
321
+                                    energySeries: energySeries,
322
+                                    voltageSeries: voltageSeries,
323
+                                    currentSeries: currentSeries,
324
+                                    temperatureSeries: temperatureSeries
325
+                                )
326
+                                .frame(width: axisColumnWidth, height: plotHeight)
327
+                            }
328
+                            .overlay(alignment: .bottom) {
329
+                                if originControlsPlacement == .aboveXAxisLegend {
330
+                                    scaleControlsPill(
331
+                                        voltageSeries: voltageSeries,
332
+                                        currentSeries: currentSeries
333
+                                    )
334
+                                    .padding(.bottom, compactLayout ? 6 : 10)
335
+                                }
336
+                            }
337
+
338
+                            switch originControlsPlacement {
339
+                            case .aboveXAxisLegend:
340
+                                xAxisLabelsView(context: primarySeries.context)
341
+                                    .frame(height: xAxisHeight)
342
+                            case .overXAxisLegend:
343
+                                xAxisLabelsView(context: primarySeries.context)
344
+                                    .frame(height: xAxisHeight)
345
+                                    .overlay(alignment: .center) {
346
+                                        scaleControlsPill(
347
+                                            voltageSeries: voltageSeries,
348
+                                            currentSeries: currentSeries
349
+                                        )
350
+                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
351
+                                    }
352
+                            case .belowXAxisLegend:
353
+                                xAxisLabelsView(context: primarySeries.context)
354
+                                    .frame(height: xAxisHeight)
355
+
356
+                                HStack {
357
+                                    Spacer(minLength: 0)
358
+                                    scaleControlsPill(
359
+                                        voltageSeries: voltageSeries,
360
+                                        currentSeries: currentSeries
361
+                                    )
362
+                                    Spacer(minLength: 0)
363
+                                }
364
+                            }
365
+
366
+                            if let availableTimeRange,
367
+                               let selectorSeries,
368
+                               shouldShowRangeSelector(
369
+                                availableTimeRange: availableTimeRange,
370
+                                series: selectorSeries
371
+                               ) {
372
+                                TimeRangeSelectorView(
373
+                                    points: selectorSeries.points,
374
+                                    context: selectorSeries.context,
375
+                                    availableTimeRange: availableTimeRange,
376
+                                    selectorTint: selectorTint,
377
+                                    compactLayout: compactLayout,
378
+                                    minimumSelectionSpan: minimumTimeSpan,
379
+                                    onKeepSelection: trimBufferToSelection,
380
+                                    onRemoveSelection: removeSelectionFromBuffer,
381
+                                    onResetBuffer: resetBuffer,
382
+                                    selectedTimeRange: $selectedVisibleTimeRange,
383
+                                    isPinnedToPresent: $isPinnedToPresent,
384
+                                    presentTrackingMode: $presentTrackingMode
385
+                                )
386
+                            }
387
+                        }
388
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
389
+                    }
390
+                    .frame(height: plotSectionHeight)
391
+                }
392
+            } else {
393
+                VStack(alignment: .leading, spacing: 12) {
394
+                    chartToggleBar()
395
+                    Text("Select at least one measurement series.")
396
+                        .foregroundColor(.secondary)
397
+                }
398
+            }
399
+        }
400
+        .font(chartBaseFont)
401
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
402
+        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
403
+            guard timeRange == nil else { return }
404
+            chartNow = now
405
+        }
406
+    }
407
+
408
+    private func chartToggleBar() -> some View {
409
+        let condensedLayout = compactLayout || verticalSizeClass == .compact
410
+        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
411
+
412
+        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
413
+            seriesToggleRow(condensedLayout: condensedLayout)
414
+        }
415
+        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
416
+        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
417
+        .background(
418
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
419
+                .fill(Color.primary.opacity(0.045))
420
+        )
421
+        .overlay(
422
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
423
+                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
424
+        )
425
+
426
+        return Group {
427
+            if stackedToolbarLayout {
428
+                controlsPanel
429
+            } else {
430
+                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
431
+                    controlsPanel
432
+                }
433
+            }
434
+        }
435
+        .frame(maxWidth: .infinity, alignment: .leading)
436
+    }
437
+
438
+    private var shouldFloatScaleControlsOverChart: Bool {
439
+        #if os(iOS)
440
+        if availableSize.width > 0, availableSize.height > 0 {
441
+            return availableSize.width > availableSize.height
442
+        }
443
+        return horizontalSizeClass != .compact && verticalSizeClass == .compact
444
+        #else
445
+        return false
446
+        #endif
447
+    }
448
+
449
+    private func scaleControlsPill(
450
+        voltageSeries: SeriesData,
451
+        currentSeries: SeriesData
452
+    ) -> some View {
453
+        let condensedLayout = compactLayout || verticalSizeClass == .compact
454
+        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
455
+        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
456
+        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
457
+
458
+        return originControlsRow(
459
+            voltageSeries: voltageSeries,
460
+            currentSeries: currentSeries,
461
+            condensedLayout: condensedLayout,
462
+            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
463
+        )
464
+        .padding(.horizontal, horizontalPadding)
465
+        .padding(.vertical, verticalPadding)
466
+        .background(
467
+            Capsule(style: .continuous)
468
+                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
469
+        )
470
+        .overlay(
471
+            Capsule(style: .continuous)
472
+                .stroke(
473
+                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
474
+                    lineWidth: 1
475
+                )
476
+        )
477
+    }
478
+
479
+    private func seriesToggleRow(condensedLayout: Bool) -> some View {
480
+        HStack(spacing: condensedLayout ? 6 : 8) {
481
+            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
482
+                displayVoltage.toggle()
483
+                if displayVoltage {
484
+                    displayPower = false
485
+                    displayEnergy = false
486
+                    if displayTemperature && displayCurrent {
487
+                        displayCurrent = false
488
+                    }
489
+                }
490
+            }
491
+
492
+            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
493
+                displayCurrent.toggle()
494
+                if displayCurrent {
495
+                    displayPower = false
496
+                    displayEnergy = false
497
+                    if displayTemperature && displayVoltage {
498
+                        displayVoltage = false
499
+                    }
500
+                }
501
+            }
502
+
503
+            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
504
+                displayPower.toggle()
505
+                if displayPower {
506
+                    displayEnergy = false
507
+                    displayCurrent = false
508
+                    displayVoltage = false
509
+                }
510
+            }
511
+
512
+            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
513
+                displayEnergy.toggle()
514
+                if displayEnergy {
515
+                    displayPower = false
516
+                    displayCurrent = false
517
+                    displayVoltage = false
518
+                }
519
+            }
520
+
521
+            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
522
+                displayTemperature.toggle()
523
+                if displayTemperature && displayVoltage && displayCurrent {
524
+                    displayCurrent = false
525
+                }
526
+            }
527
+        }
528
+    }
529
+
530
+    private func originControlsRow(
531
+        voltageSeries: SeriesData,
532
+        currentSeries: SeriesData,
533
+        condensedLayout: Bool,
534
+        showsLabel: Bool
535
+    ) -> some View {
536
+        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
537
+            if supportsSharedOrigin {
538
+                symbolControlChip(
539
+                    systemImage: "equal.circle",
540
+                    enabled: true,
541
+                    active: useSharedOrigin,
542
+                    condensedLayout: condensedLayout,
543
+                    showsLabel: showsLabel,
544
+                    label: "Match Y Scale",
545
+                    accessibilityLabel: "Match Y scale"
546
+                ) {
547
+                    toggleSharedOrigin(
548
+                        voltageSeries: voltageSeries,
549
+                        currentSeries: currentSeries
550
+                    )
551
+                }
552
+            }
553
+
554
+            symbolControlChip(
555
+                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
556
+                enabled: true,
557
+                active: pinOrigin,
558
+                condensedLayout: condensedLayout,
559
+                showsLabel: showsLabel,
560
+                label: pinOrigin ? "Origin Locked" : "Origin Auto",
561
+                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
562
+            ) {
563
+                togglePinnedOrigin(
564
+                    voltageSeries: voltageSeries,
565
+                    currentSeries: currentSeries
566
+                )
567
+            }
568
+
569
+            if !pinnedOriginIsZero {
570
+                symbolControlChip(
571
+                    systemImage: "0.circle",
572
+                    enabled: true,
573
+                    active: false,
574
+                    condensedLayout: condensedLayout,
575
+                    showsLabel: showsLabel,
576
+                    label: "Origin 0",
577
+                    accessibilityLabel: "Set origin to zero"
578
+                ) {
579
+                    setVisibleOriginsToZero()
580
+                }
581
+            }
582
+
583
+            smoothingControlChip(
584
+                condensedLayout: condensedLayout,
585
+                showsLabel: showsLabel
586
+            )
587
+
588
+        }
589
+    }
590
+
591
+    private func smoothingControlChip(
592
+        condensedLayout: Bool,
593
+        showsLabel: Bool
594
+    ) -> some View {
595
+        Menu {
596
+            ForEach(SmoothingLevel.allCases, id: \.self) { level in
597
+                Button {
598
+                    smoothingLevel = level
599
+                } label: {
600
+                    if smoothingLevel == level {
601
+                        Label(level.label, systemImage: "checkmark")
602
+                    } else {
603
+                        Text(level.label)
604
+                    }
605
+                }
606
+            }
607
+        } label: {
608
+            Group {
609
+                if showsLabel {
610
+                    VStack(alignment: .leading, spacing: 2) {
611
+                        Label("Smoothing", systemImage: "waveform.path")
612
+                            .font(controlChipFont(condensedLayout: condensedLayout))
613
+
614
+                        Text(
615
+                            smoothingLevel == .off
616
+                            ? "Off"
617
+                            : "MA \(smoothingLevel.movingAverageWindowSize)"
618
+                        )
619
+                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
620
+                        .foregroundColor(.secondary)
621
+                        .monospacedDigit()
622
+                    }
623
+                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
624
+                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
625
+                } else {
626
+                    VStack(spacing: 1) {
627
+                        Image(systemName: "waveform.path")
628
+                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
629
+
630
+                        Text(smoothingLevel.shortLabel)
631
+                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
632
+                            .monospacedDigit()
633
+                    }
634
+                    .frame(
635
+                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
636
+                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
637
+                    )
638
+                }
639
+            }
640
+            .background(
641
+                Capsule(style: .continuous)
642
+                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
643
+            )
644
+            .overlay(
645
+                Capsule(style: .continuous)
646
+                    .stroke(
647
+                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
648
+                        lineWidth: 1
649
+                    )
650
+            )
651
+        }
652
+        .buttonStyle(.plain)
653
+        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
654
+    }
655
+
656
+    private func seriesToggleButton(
657
+        title: String,
658
+        isOn: Bool,
659
+        condensedLayout: Bool,
660
+        action: @escaping () -> Void
661
+    ) -> some View {
662
+        Button(action: action) {
663
+            Text(title)
664
+                .font(seriesToggleFont(condensedLayout: condensedLayout))
665
+                .lineLimit(1)
666
+                .minimumScaleFactor(0.82)
667
+                .foregroundColor(isOn ? .white : .blue)
668
+                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
669
+                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
670
+                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
671
+                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
672
+                .background(
673
+                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
674
+                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
675
+                )
676
+                .overlay(
677
+                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
678
+                        .stroke(Color.blue, lineWidth: 1.5)
679
+                )
680
+        }
681
+        .buttonStyle(.plain)
682
+    }
683
+
684
+    private func symbolControlChip(
685
+        systemImage: String,
686
+        enabled: Bool,
687
+        active: Bool,
688
+        condensedLayout: Bool,
689
+        showsLabel: Bool,
690
+        label: String,
691
+        accessibilityLabel: String,
692
+        action: @escaping () -> Void
693
+    ) -> some View {
694
+        Button(action: {
695
+            action()
696
+        }) {
697
+            Group {
698
+                if showsLabel {
699
+                    Label(label, systemImage: systemImage)
700
+                        .font(controlChipFont(condensedLayout: condensedLayout))
701
+                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
702
+                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
703
+                } else {
704
+                    Image(systemName: systemImage)
705
+                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
706
+                        .frame(
707
+                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
708
+                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
709
+                        )
710
+                }
711
+            }
712
+                .background(
713
+                    Capsule(style: .continuous)
714
+                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
715
+                )
716
+        }
717
+        .buttonStyle(.plain)
718
+        .foregroundColor(enabled ? .primary : .secondary)
719
+        .opacity(enabled ? 1 : 0.55)
720
+        .accessibilityLabel(accessibilityLabel)
721
+    }
722
+
723
+    private func resetBuffer() {
724
+        measurements.resetSeries()
725
+    }
726
+
727
+    private func seriesToggleFont(condensedLayout: Bool) -> Font {
728
+        if isLargeDisplay {
729
+            return .body.weight(.semibold)
730
+        }
731
+        return (condensedLayout ? Font.callout : .body).weight(.semibold)
732
+    }
733
+
734
+    private func controlChipFont(condensedLayout: Bool) -> Font {
735
+        if isLargeDisplay {
736
+            return .callout.weight(.semibold)
737
+        }
738
+        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
739
+    }
740
+
741
+    @ViewBuilder
742
+    private func primaryAxisView(
743
+        height: CGFloat,
744
+        powerSeries: SeriesData,
745
+        energySeries: SeriesData,
746
+        voltageSeries: SeriesData,
747
+        currentSeries: SeriesData
748
+    ) -> some View {
749
+        if displayPower {
750
+            yAxisLabelsView(
751
+                height: height,
752
+                context: powerSeries.context,
753
+                seriesKind: .power,
754
+                measurementUnit: powerSeries.kind.unit,
755
+                tint: powerSeries.kind.tint
756
+            )
757
+        } else if displayEnergy {
758
+            yAxisLabelsView(
759
+                height: height,
760
+                context: energySeries.context,
761
+                seriesKind: .energy,
762
+                measurementUnit: energySeries.kind.unit,
763
+                tint: energySeries.kind.tint
764
+            )
765
+        } else if displayVoltage {
766
+            yAxisLabelsView(
767
+                height: height,
768
+                context: voltageSeries.context,
769
+                seriesKind: .voltage,
770
+                measurementUnit: voltageSeries.kind.unit,
771
+                tint: voltageSeries.kind.tint
772
+            )
773
+        } else if displayCurrent {
774
+            yAxisLabelsView(
775
+                height: height,
776
+                context: currentSeries.context,
777
+                seriesKind: .current,
778
+                measurementUnit: currentSeries.kind.unit,
779
+                tint: currentSeries.kind.tint
780
+            )
781
+        }
782
+    }
783
+
784
+    @ViewBuilder
785
+    private func renderedChart(
786
+        powerSeries: SeriesData,
787
+        energySeries: SeriesData,
788
+        voltageSeries: SeriesData,
789
+        currentSeries: SeriesData,
790
+        temperatureSeries: SeriesData
791
+    ) -> some View {
792
+        if self.displayPower {
793
+            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
794
+                .opacity(0.72)
795
+        } else if self.displayEnergy {
796
+            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
797
+                .opacity(0.78)
798
+        } else {
799
+            if self.displayVoltage {
800
+                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
801
+                    .opacity(0.78)
802
+            }
803
+            if self.displayCurrent {
804
+                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
805
+                    .opacity(0.78)
806
+            }
807
+        }
808
+
809
+        if displayTemperature {
810
+            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
811
+                .opacity(0.86)
812
+        }
813
+    }
814
+
815
+    @ViewBuilder
816
+    private func secondaryAxisView(
817
+        height: CGFloat,
818
+        powerSeries: SeriesData,
819
+        energySeries: SeriesData,
820
+        voltageSeries: SeriesData,
821
+        currentSeries: SeriesData,
822
+        temperatureSeries: SeriesData
823
+    ) -> some View {
824
+        if displayTemperature {
825
+            yAxisLabelsView(
826
+                height: height,
827
+                context: temperatureSeries.context,
828
+                seriesKind: .temperature,
829
+                measurementUnit: measurementUnit(for: .temperature),
830
+                tint: temperatureSeries.kind.tint
831
+            )
832
+        } else if displayVoltage && displayCurrent {
833
+            yAxisLabelsView(
834
+                height: height,
835
+                context: currentSeries.context,
836
+                seriesKind: .current,
837
+                measurementUnit: currentSeries.kind.unit,
838
+                tint: currentSeries.kind.tint
839
+            )
840
+        } else {
841
+            primaryAxisView(
842
+                height: height,
843
+                powerSeries: powerSeries,
844
+                energySeries: energySeries,
845
+                voltageSeries: voltageSeries,
846
+                currentSeries: currentSeries
847
+            )
848
+        }
849
+    }
850
+
851
+    private func displayedPrimarySeries(
852
+        powerSeries: SeriesData,
853
+        energySeries: SeriesData,
854
+        voltageSeries: SeriesData,
855
+        currentSeries: SeriesData
856
+    ) -> SeriesData? {
857
+        if displayPower {
858
+            return powerSeries
859
+        }
860
+        if displayEnergy {
861
+            return energySeries
862
+        }
863
+        if displayVoltage {
864
+            return voltageSeries
865
+        }
866
+        if displayCurrent {
867
+            return currentSeries
868
+        }
869
+        return nil
870
+    }
871
+
872
+    private func series(
873
+        for measurement: Measurements.Measurement,
874
+        kind: SeriesKind,
875
+        minimumYSpan: Double,
876
+        visibleTimeRange: ClosedRange<Date>? = nil
877
+    ) -> SeriesData {
878
+        let rawPoints = filteredPoints(
879
+            measurement,
880
+            visibleTimeRange: visibleTimeRange
881
+        )
882
+        let points = smoothedPoints(from: rawPoints)
883
+        let samplePoints = points.filter { $0.isSample }
884
+        let context = ChartContext()
885
+
886
+        let autoBounds = automaticYBounds(
887
+            for: samplePoints,
888
+            minimumYSpan: minimumYSpan
889
+        )
890
+        let xBounds = xBounds(
891
+            for: samplePoints,
892
+            visibleTimeRange: visibleTimeRange
893
+        )
894
+        let lowerBound = resolvedLowerBound(
895
+            for: kind,
896
+            autoLowerBound: autoBounds.lowerBound
897
+        )
898
+        let upperBound = resolvedUpperBound(
899
+            for: kind,
900
+            lowerBound: lowerBound,
901
+            autoUpperBound: autoBounds.upperBound,
902
+            maximumSampleValue: samplePoints.map(\.value).max(),
903
+            minimumYSpan: minimumYSpan
904
+        )
905
+
906
+        context.setBounds(
907
+            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
908
+            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
909
+            yMin: CGFloat(lowerBound),
910
+            yMax: CGFloat(upperBound)
911
+        )
912
+
913
+        return SeriesData(
914
+            kind: kind,
915
+            points: points,
916
+            samplePoints: samplePoints,
917
+            context: context,
918
+            autoLowerBound: autoBounds.lowerBound,
919
+            autoUpperBound: autoBounds.upperBound,
920
+            maximumSampleValue: samplePoints.map(\.value).max()
921
+        )
922
+    }
923
+
924
+    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
925
+        series(
926
+            for: measurement(for: kind),
927
+            kind: kind,
928
+            minimumYSpan: minimumYSpan(for: kind)
929
+        )
930
+    }
931
+
932
+    private func smoothedPoints(
933
+        from points: [Measurements.Measurement.Point]
934
+    ) -> [Measurements.Measurement.Point] {
935
+        guard smoothingLevel != .off else { return points }
936
+
937
+        var smoothedPoints: [Measurements.Measurement.Point] = []
938
+        var currentSegment: [Measurements.Measurement.Point] = []
939
+
940
+        func flushCurrentSegment() {
941
+            guard !currentSegment.isEmpty else { return }
942
+
943
+            for point in smoothedSegment(currentSegment) {
944
+                smoothedPoints.append(
945
+                    Measurements.Measurement.Point(
946
+                        id: smoothedPoints.count,
947
+                        timestamp: point.timestamp,
948
+                        value: point.value,
949
+                        kind: .sample
950
+                    )
951
+                )
952
+            }
953
+
954
+            currentSegment.removeAll(keepingCapacity: true)
955
+        }
956
+
957
+        for point in points {
958
+            if point.isDiscontinuity {
959
+                flushCurrentSegment()
960
+
961
+                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
962
+                    smoothedPoints.append(
963
+                        Measurements.Measurement.Point(
964
+                            id: smoothedPoints.count,
965
+                            timestamp: point.timestamp,
966
+                            value: smoothedPoints.last?.value ?? point.value,
967
+                            kind: .discontinuity
968
+                        )
969
+                    )
970
+                }
971
+            } else {
972
+                currentSegment.append(point)
973
+            }
974
+        }
975
+
976
+        flushCurrentSegment()
977
+        return smoothedPoints
978
+    }
979
+
980
+    private func smoothedSegment(
981
+        _ segment: [Measurements.Measurement.Point]
982
+    ) -> [Measurements.Measurement.Point] {
983
+        let windowSize = smoothingLevel.movingAverageWindowSize
984
+        guard windowSize > 1, segment.count > 2 else { return segment }
985
+
986
+        let radius = windowSize / 2
987
+        var prefixSums: [Double] = [0]
988
+        prefixSums.reserveCapacity(segment.count + 1)
989
+
990
+        for point in segment {
991
+            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
992
+        }
993
+
994
+        return segment.enumerated().map { index, point in
995
+            let lowerBound = max(0, index - radius)
996
+            let upperBound = min(segment.count - 1, index + radius)
997
+            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
998
+            let average = sum / Double(upperBound - lowerBound + 1)
999
+
1000
+            return Measurements.Measurement.Point(
1001
+                id: point.id,
1002
+                timestamp: point.timestamp,
1003
+                value: average,
1004
+                kind: .sample
1005
+            )
1006
+        }
1007
+    }
1008
+
1009
+    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1010
+        switch kind {
1011
+        case .power:
1012
+            return measurements.power
1013
+        case .energy:
1014
+            return measurements.energy
1015
+        case .voltage:
1016
+            return measurements.voltage
1017
+        case .current:
1018
+            return measurements.current
1019
+        case .temperature:
1020
+            return measurements.temperature
1021
+        }
1022
+    }
1023
+
1024
+    private func minimumYSpan(for kind: SeriesKind) -> Double {
1025
+        switch kind {
1026
+        case .power:
1027
+            return minimumPowerSpan
1028
+        case .energy:
1029
+            return minimumEnergySpan
1030
+        case .voltage:
1031
+            return minimumVoltageSpan
1032
+        case .current:
1033
+            return minimumCurrentSpan
1034
+        case .temperature:
1035
+            return minimumTemperatureSpan
1036
+        }
1037
+    }
1038
+
1039
+    private var supportsSharedOrigin: Bool {
1040
+        displayVoltage && displayCurrent && !displayPower && !displayEnergy
1041
+    }
1042
+
1043
+    private var minimumSharedScaleSpan: Double {
1044
+        max(minimumVoltageSpan, minimumCurrentSpan)
1045
+    }
1046
+
1047
+    private var pinnedOriginIsZero: Bool {
1048
+        if useSharedOrigin && supportsSharedOrigin {
1049
+            return pinOrigin && sharedAxisOrigin == 0
1050
+        }
1051
+
1052
+        if displayPower {
1053
+            return pinOrigin && powerAxisOrigin == 0
1054
+        }
1055
+
1056
+        if displayEnergy {
1057
+            return pinOrigin && energyAxisOrigin == 0
1058
+        }
1059
+
1060
+        let visibleOrigins = [
1061
+            displayVoltage ? voltageAxisOrigin : nil,
1062
+            displayCurrent ? currentAxisOrigin : nil
1063
+        ]
1064
+        .compactMap { $0 }
1065
+
1066
+        guard !visibleOrigins.isEmpty else { return false }
1067
+        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1068
+    }
1069
+
1070
+    private func toggleSharedOrigin(
1071
+        voltageSeries: SeriesData,
1072
+        currentSeries: SeriesData
1073
+    ) {
1074
+        guard supportsSharedOrigin else { return }
1075
+
1076
+        if useSharedOrigin {
1077
+            useSharedOrigin = false
1078
+            return
1079
+        }
1080
+
1081
+        captureCurrentOrigins(
1082
+            voltageSeries: voltageSeries,
1083
+            currentSeries: currentSeries
1084
+        )
1085
+        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
1086
+        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1087
+        ensureSharedScaleSpan()
1088
+        useSharedOrigin = true
1089
+        pinOrigin = true
1090
+    }
1091
+
1092
+    private func togglePinnedOrigin(
1093
+        voltageSeries: SeriesData,
1094
+        currentSeries: SeriesData
1095
+    ) {
1096
+        if pinOrigin {
1097
+            pinOrigin = false
1098
+            return
1099
+        }
1100
+
1101
+        captureCurrentOrigins(
1102
+            voltageSeries: voltageSeries,
1103
+            currentSeries: currentSeries
1104
+        )
1105
+        pinOrigin = true
1106
+    }
1107
+
1108
+    private func setVisibleOriginsToZero() {
1109
+        if useSharedOrigin && supportsSharedOrigin {
1110
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1111
+            sharedAxisOrigin = 0
1112
+            sharedAxisUpperBound = currentSpan
1113
+            voltageAxisOrigin = 0
1114
+            currentAxisOrigin = 0
1115
+            ensureSharedScaleSpan()
1116
+        } else {
1117
+            if displayPower {
1118
+                powerAxisOrigin = 0
1119
+            }
1120
+            if displayEnergy {
1121
+                energyAxisOrigin = 0
1122
+            }
1123
+            if displayVoltage {
1124
+                voltageAxisOrigin = 0
1125
+            }
1126
+            if displayCurrent {
1127
+                currentAxisOrigin = 0
1128
+            }
1129
+            if displayTemperature {
1130
+                temperatureAxisOrigin = 0
1131
+            }
1132
+        }
1133
+
1134
+        pinOrigin = true
1135
+    }
1136
+
1137
+    private func captureCurrentOrigins(
1138
+        voltageSeries: SeriesData,
1139
+        currentSeries: SeriesData
1140
+    ) {
1141
+        powerAxisOrigin = displayedLowerBoundForSeries(.power)
1142
+        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
1143
+        voltageAxisOrigin = voltageSeries.autoLowerBound
1144
+        currentAxisOrigin = currentSeries.autoLowerBound
1145
+        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
1146
+        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
1147
+        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1148
+        ensureSharedScaleSpan()
1149
+    }
1150
+
1151
+    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
1152
+        let visibleTimeRange = activeVisibleTimeRange
1153
+
1154
+        switch kind {
1155
+        case .power:
1156
+            return pinOrigin
1157
+                ? powerAxisOrigin
1158
+                : automaticYBounds(
1159
+                    for: filteredSamplePoints(
1160
+                        measurements.power,
1161
+                        visibleTimeRange: visibleTimeRange
1162
+                    ),
1163
+                    minimumYSpan: minimumPowerSpan
1164
+                ).lowerBound
1165
+        case .energy:
1166
+            return pinOrigin
1167
+                ? energyAxisOrigin
1168
+                : automaticYBounds(
1169
+                    for: filteredSamplePoints(
1170
+                        measurements.energy,
1171
+                        visibleTimeRange: visibleTimeRange
1172
+                    ),
1173
+                    minimumYSpan: minimumEnergySpan
1174
+                ).lowerBound
1175
+        case .voltage:
1176
+            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1177
+                return sharedAxisOrigin
1178
+            }
1179
+            return pinOrigin
1180
+                ? voltageAxisOrigin
1181
+                : automaticYBounds(
1182
+                    for: filteredSamplePoints(
1183
+                        measurements.voltage,
1184
+                        visibleTimeRange: visibleTimeRange
1185
+                    ),
1186
+                    minimumYSpan: minimumVoltageSpan
1187
+                ).lowerBound
1188
+        case .current:
1189
+            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1190
+                return sharedAxisOrigin
1191
+            }
1192
+            return pinOrigin
1193
+                ? currentAxisOrigin
1194
+                : automaticYBounds(
1195
+                    for: filteredSamplePoints(
1196
+                        measurements.current,
1197
+                        visibleTimeRange: visibleTimeRange
1198
+                    ),
1199
+                    minimumYSpan: minimumCurrentSpan
1200
+                ).lowerBound
1201
+        case .temperature:
1202
+            return pinOrigin
1203
+                ? temperatureAxisOrigin
1204
+                : automaticYBounds(
1205
+                    for: filteredSamplePoints(
1206
+                        measurements.temperature,
1207
+                        visibleTimeRange: visibleTimeRange
1208
+                    ),
1209
+                    minimumYSpan: minimumTemperatureSpan
1210
+                ).lowerBound
1211
+        }
1212
+    }
1213
+
1214
+    private var activeVisibleTimeRange: ClosedRange<Date>? {
1215
+        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1216
+    }
1217
+
1218
+    private func filteredPoints(
1219
+        _ measurement: Measurements.Measurement,
1220
+        visibleTimeRange: ClosedRange<Date>? = nil
1221
+    ) -> [Measurements.Measurement.Point] {
1222
+        let resolvedRange: ClosedRange<Date>?
1223
+
1224
+        switch (timeRange, visibleTimeRange) {
1225
+        case let (baseRange?, visibleRange?):
1226
+            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1227
+            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1228
+            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1229
+        case let (baseRange?, nil):
1230
+            resolvedRange = baseRange
1231
+        case let (nil, visibleRange?):
1232
+            resolvedRange = visibleRange
1233
+        case (nil, nil):
1234
+            resolvedRange = nil
1235
+        }
1236
+
1237
+        guard let resolvedRange else {
1238
+            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1239
+        }
1240
+
1241
+        return measurement.points(in: resolvedRange)
1242
+    }
1243
+
1244
+    private func filteredSamplePoints(
1245
+        _ measurement: Measurements.Measurement,
1246
+        visibleTimeRange: ClosedRange<Date>? = nil
1247
+    ) -> [Measurements.Measurement.Point] {
1248
+        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1249
+            point.isSample
1250
+        }
1251
+    }
1252
+
1253
+    private func xBounds(
1254
+        for samplePoints: [Measurements.Measurement.Point],
1255
+        visibleTimeRange: ClosedRange<Date>? = nil
1256
+    ) -> ClosedRange<Date> {
1257
+        if let visibleTimeRange {
1258
+            return normalizedTimeRange(visibleTimeRange)
1259
+        }
1260
+
1261
+        if let timeRange {
1262
+            return normalizedTimeRange(timeRange)
1263
+        }
1264
+
1265
+        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1266
+        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1267
+
1268
+        return normalizedTimeRange(lowerBound...upperBound)
1269
+    }
1270
+
1271
+    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1272
+        if let timeRange {
1273
+            return normalizedTimeRange(timeRange)
1274
+        }
1275
+
1276
+        let samplePoints = timelineSamplePoints()
1277
+        guard let lowerBound = samplePoints.first?.timestamp else {
1278
+            return nil
1279
+        }
1280
+
1281
+        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1282
+        return normalizedTimeRange(lowerBound...upperBound)
1283
+    }
1284
+
1285
+    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1286
+        let candidates = [
1287
+            filteredSamplePoints(measurements.power),
1288
+            filteredSamplePoints(measurements.energy),
1289
+            filteredSamplePoints(measurements.voltage),
1290
+            filteredSamplePoints(measurements.current),
1291
+            filteredSamplePoints(measurements.temperature)
1292
+        ]
1293
+
1294
+        return candidates.first(where: { !$0.isEmpty }) ?? []
1295
+    }
1296
+
1297
+    private func resolvedVisibleTimeRange(
1298
+        within availableTimeRange: ClosedRange<Date>?
1299
+    ) -> ClosedRange<Date>? {
1300
+        guard let availableTimeRange else { return nil }
1301
+        guard let selectedVisibleTimeRange else { return availableTimeRange }
1302
+
1303
+        if isPinnedToPresent {
1304
+            let pinnedRange: ClosedRange<Date>
1305
+
1306
+            switch presentTrackingMode {
1307
+            case .keepDuration:
1308
+                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1309
+                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1310
+            case .keepStartTimestamp:
1311
+                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1312
+            }
1313
+
1314
+            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1315
+        }
1316
+
1317
+        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1318
+    }
1319
+
1320
+    private func clampedTimeRange(
1321
+        _ candidateRange: ClosedRange<Date>,
1322
+        within bounds: ClosedRange<Date>
1323
+    ) -> ClosedRange<Date> {
1324
+        let normalizedBounds = normalizedTimeRange(bounds)
1325
+        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1326
+
1327
+        guard boundsSpan > 0 else {
1328
+            return normalizedBounds
1329
+        }
1330
+
1331
+        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1332
+        let requestedSpan = min(
1333
+            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1334
+            boundsSpan
1335
+        )
1336
+
1337
+        if requestedSpan >= boundsSpan {
1338
+            return normalizedBounds
1339
+        }
1340
+
1341
+        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1342
+        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1343
+
1344
+        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1345
+            if lowerBound == normalizedBounds.lowerBound {
1346
+                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1347
+            } else {
1348
+                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1349
+            }
1350
+        }
1351
+
1352
+        if upperBound > normalizedBounds.upperBound {
1353
+            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1354
+            upperBound = normalizedBounds.upperBound
1355
+            lowerBound = lowerBound.addingTimeInterval(-delta)
1356
+        }
1357
+
1358
+        if lowerBound < normalizedBounds.lowerBound {
1359
+            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1360
+            lowerBound = normalizedBounds.lowerBound
1361
+            upperBound = upperBound.addingTimeInterval(delta)
1362
+        }
1363
+
1364
+        return lowerBound...upperBound
1365
+    }
1366
+
1367
+    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1368
+        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1369
+        guard span < minimumTimeSpan else { return range }
1370
+
1371
+        let expansion = (minimumTimeSpan - span) / 2
1372
+        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1373
+    }
1374
+
1375
+    private func shouldShowRangeSelector(
1376
+        availableTimeRange: ClosedRange<Date>,
1377
+        series: SeriesData
1378
+    ) -> Bool {
1379
+        series.samplePoints.count > 1 &&
1380
+        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
1381
+    }
1382
+
1383
+    private func automaticYBounds(
1384
+        for samplePoints: [Measurements.Measurement.Point],
1385
+        minimumYSpan: Double
1386
+    ) -> (lowerBound: Double, upperBound: Double) {
1387
+        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1388
+
1389
+        guard
1390
+            let minimumSampleValue = samplePoints.map(\.value).min(),
1391
+            let maximumSampleValue = samplePoints.map(\.value).max()
1392
+        else {
1393
+            return (0, minimumYSpan)
1394
+        }
1395
+
1396
+        var lowerBound = minimumSampleValue
1397
+        var upperBound = maximumSampleValue
1398
+        let currentSpan = upperBound - lowerBound
1399
+
1400
+        if currentSpan < minimumYSpan {
1401
+            let expansion = (minimumYSpan - currentSpan) / 2
1402
+            lowerBound -= expansion
1403
+            upperBound += expansion
1404
+        }
1405
+
1406
+        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1407
+            let shift = -negativeAllowance - lowerBound
1408
+            lowerBound += shift
1409
+            upperBound += shift
1410
+        }
1411
+
1412
+        let snappedLowerBound = snappedOriginValue(lowerBound)
1413
+        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1414
+        return (snappedLowerBound, resolvedUpperBound)
1415
+    }
1416
+
1417
+    private func resolvedLowerBound(
1418
+        for kind: SeriesKind,
1419
+        autoLowerBound: Double
1420
+    ) -> Double {
1421
+        guard pinOrigin else { return autoLowerBound }
1422
+
1423
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1424
+            return sharedAxisOrigin
1425
+        }
1426
+
1427
+        switch kind {
1428
+        case .power:
1429
+            return powerAxisOrigin
1430
+        case .energy:
1431
+            return energyAxisOrigin
1432
+        case .voltage:
1433
+            return voltageAxisOrigin
1434
+        case .current:
1435
+            return currentAxisOrigin
1436
+        case .temperature:
1437
+            return temperatureAxisOrigin
1438
+        }
1439
+    }
1440
+
1441
+    private func resolvedUpperBound(
1442
+        for kind: SeriesKind,
1443
+        lowerBound: Double,
1444
+        autoUpperBound: Double,
1445
+        maximumSampleValue: Double?,
1446
+        minimumYSpan: Double
1447
+    ) -> Double {
1448
+        guard pinOrigin else {
1449
+            return autoUpperBound
1450
+        }
1451
+
1452
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1453
+            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1454
+        }
1455
+
1456
+        if kind == .temperature {
1457
+            return autoUpperBound
1458
+        }
1459
+
1460
+        return max(
1461
+            maximumSampleValue ?? lowerBound,
1462
+            lowerBound + minimumYSpan,
1463
+            autoUpperBound
1464
+        )
1465
+    }
1466
+
1467
+    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
1468
+        let baseline = displayedLowerBoundForSeries(kind)
1469
+        let proposedOrigin = snappedOriginValue(baseline + delta)
1470
+
1471
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1472
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1473
+            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
1474
+            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1475
+            ensureSharedScaleSpan()
1476
+        } else {
1477
+            switch kind {
1478
+            case .power:
1479
+                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
1480
+            case .energy:
1481
+                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
1482
+            case .voltage:
1483
+                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1484
+            case .current:
1485
+                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
1486
+            case .temperature:
1487
+                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
1488
+            }
1489
+        }
1490
+
1491
+        pinOrigin = true
1492
+    }
1493
+
1494
+    private func clearOriginOffset(for kind: SeriesKind) {
1495
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1496
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1497
+            sharedAxisOrigin = 0
1498
+            sharedAxisUpperBound = currentSpan
1499
+            ensureSharedScaleSpan()
1500
+            voltageAxisOrigin = 0
1501
+            currentAxisOrigin = 0
1502
+        } else {
1503
+            switch kind {
1504
+            case .power:
1505
+                powerAxisOrigin = 0
1506
+            case .energy:
1507
+                energyAxisOrigin = 0
1508
+            case .voltage:
1509
+                voltageAxisOrigin = 0
1510
+            case .current:
1511
+                currentAxisOrigin = 0
1512
+            case .temperature:
1513
+                temperatureAxisOrigin = 0
1514
+            }
1515
+        }
1516
+
1517
+        pinOrigin = true
1518
+    }
1519
+
1520
+    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1521
+        guard totalHeight > 1 else { return }
1522
+
1523
+        let normalized = max(0, min(1, locationY / totalHeight))
1524
+        if normalized < (1.0 / 3.0) {
1525
+            applyOriginDelta(-1, kind: kind)
1526
+        } else if normalized < (2.0 / 3.0) {
1527
+            clearOriginOffset(for: kind)
1528
+        } else {
1529
+            applyOriginDelta(1, kind: kind)
1530
+        }
1531
+    }
1532
+
1533
+    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
1534
+        let visibleTimeRange = activeVisibleTimeRange
1535
+
1536
+        switch kind {
1537
+        case .power:
1538
+            return snappedOriginValue(
1539
+                filteredSamplePoints(
1540
+                    measurements.power,
1541
+                    visibleTimeRange: visibleTimeRange
1542
+                ).map(\.value).min() ?? 0
1543
+            )
1544
+        case .energy:
1545
+            return snappedOriginValue(
1546
+                filteredSamplePoints(
1547
+                    measurements.energy,
1548
+                    visibleTimeRange: visibleTimeRange
1549
+                ).map(\.value).min() ?? 0
1550
+            )
1551
+        case .voltage:
1552
+            return snappedOriginValue(
1553
+                filteredSamplePoints(
1554
+                    measurements.voltage,
1555
+                    visibleTimeRange: visibleTimeRange
1556
+                ).map(\.value).min() ?? 0
1557
+            )
1558
+        case .current:
1559
+            return snappedOriginValue(
1560
+                filteredSamplePoints(
1561
+                    measurements.current,
1562
+                    visibleTimeRange: visibleTimeRange
1563
+                ).map(\.value).min() ?? 0
1564
+            )
1565
+        case .temperature:
1566
+            return snappedOriginValue(
1567
+                filteredSamplePoints(
1568
+                    measurements.temperature,
1569
+                    visibleTimeRange: visibleTimeRange
1570
+                ).map(\.value).min() ?? 0
1571
+            )
1572
+        }
1573
+    }
1574
+
1575
+    private func maximumVisibleSharedOrigin() -> Double {
1576
+        min(
1577
+            maximumVisibleOrigin(for: .voltage),
1578
+            maximumVisibleOrigin(for: .current)
1579
+        )
1580
+    }
1581
+
1582
+    private func measurementUnit(for kind: SeriesKind) -> String {
1583
+        switch kind {
1584
+        case .temperature:
1585
+            let locale = Locale.autoupdatingCurrent
1586
+            if #available(iOS 16.0, *) {
1587
+                switch locale.measurementSystem {
1588
+                case .us:
1589
+                    return "°F"
1590
+                default:
1591
+                    return "°C"
1592
+                }
1593
+            }
1594
+
1595
+            let regionCode = locale.regionCode ?? ""
1596
+            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1597
+            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1598
+        default:
1599
+            return kind.unit
1600
+        }
1601
+    }
1602
+
1603
+    private func ensureSharedScaleSpan() {
1604
+        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1605
+    }
1606
+
1607
+    private func snappedOriginValue(_ value: Double) -> Double {
1608
+        if value >= 0 {
1609
+            return value.rounded(.down)
1610
+        }
1611
+
1612
+        return value.rounded(.up)
1613
+    }
1614
+
1615
+    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1616
+        measurements.keepOnly(in: range)
1617
+        selectedVisibleTimeRange = nil
1618
+        isPinnedToPresent = false
1619
+    }
1620
+
1621
+    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1622
+        measurements.removeValues(in: range)
1623
+        selectedVisibleTimeRange = nil
1624
+        isPinnedToPresent = false
1625
+    }
1626
+
1627
+    private func yGuidePosition(
1628
+        for labelIndex: Int,
1629
+        context: ChartContext,
1630
+        height: CGFloat
1631
+    ) -> CGFloat {
1632
+        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1633
+        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1634
+        return context.placeInRect(point: anchorPoint).y * height
1635
+    }
1636
+
1637
+    private func xGuidePosition(
1638
+        for labelIndex: Int,
1639
+        context: ChartContext,
1640
+        width: CGFloat
1641
+    ) -> CGFloat {
1642
+        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1643
+        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1644
+        return context.placeInRect(point: anchorPoint).x * width
1645
+    }
1646
+    
1647
+    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
1648
+    fileprivate func xAxisLabelsView(
1649
+        context: ChartContext
1650
+    ) -> some View {
1651
+        var timeFormat: String?
1652
+        switch context.size.width {
1653
+        case 0..<3600: timeFormat = "HH:mm:ss"
1654
+        case 3600...86400: timeFormat = "HH:mm"
1655
+        default: timeFormat = "E HH:mm"
1656
+        }
1657
+        let labels = (1...xLabels).map {
1658
+            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
1659
+        }
1660
+        let axisLabelFont: Font = {
1661
+            if isIPhone && isPortraitLayout {
1662
+                return .caption2.weight(.semibold)
1663
+            }
1664
+            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1665
+        }()
1666
+
1667
+        return HStack(spacing: chartSectionSpacing) {
1668
+            Color.clear
1669
+                .frame(width: axisColumnWidth)
1670
+
1671
+            GeometryReader { geometry in
1672
+                let labelWidth = max(
1673
+                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1674
+                    1
1675
+                )
1676
+
1677
+                ZStack(alignment: .topLeading) {
1678
+                    Path { path in
1679
+                        for labelIndex in 1...self.xLabels {
1680
+                            let x = xGuidePosition(
1681
+                                for: labelIndex,
1682
+                                context: context,
1683
+                                width: geometry.size.width
1684
+                            )
1685
+                            path.move(to: CGPoint(x: x, y: 0))
1686
+                            path.addLine(to: CGPoint(x: x, y: 6))
1687
+                        }
1688
+                    }
1689
+                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1690
+
1691
+                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1692
+                        let labelIndex = item.offset + 1
1693
+                        let centerX = xGuidePosition(
1694
+                            for: labelIndex,
1695
+                            context: context,
1696
+                            width: geometry.size.width
1697
+                        )
1698
+
1699
+                        Text(item.element)
1700
+                            .font(axisLabelFont)
1701
+                            .monospacedDigit()
1702
+                            .lineLimit(1)
1703
+                            .minimumScaleFactor(0.74)
1704
+                            .frame(width: labelWidth)
1705
+                            .position(
1706
+                                x: centerX,
1707
+                                y: geometry.size.height * 0.7
1708
+                            )
1709
+                    }
1710
+                }
1711
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
1712
+            }
1713
+
1714
+            Color.clear
1715
+                .frame(width: axisColumnWidth)
1716
+        }
1717
+    }
1718
+    
1719
+    private func yAxisLabelsView(
1720
+        height: CGFloat,
1721
+        context: ChartContext,
1722
+        seriesKind: SeriesKind,
1723
+        measurementUnit: String,
1724
+        tint: Color
1725
+    ) -> some View {
1726
+        let yAxisFont: Font = {
1727
+            if isIPhone && isPortraitLayout {
1728
+                return .caption2.weight(.semibold)
1729
+            }
1730
+            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1731
+        }()
1732
+
1733
+        let unitFont: Font = {
1734
+            if isIPhone && isPortraitLayout {
1735
+                return .caption2.weight(.bold)
1736
+            }
1737
+            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1738
+        }()
1739
+
1740
+        return GeometryReader { geometry in
1741
+            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1742
+            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1743
+            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1744
+
1745
+            ZStack(alignment: .top) {
1746
+                ForEach(0..<yLabels, id: \.self) { row in
1747
+                    let labelIndex = yLabels - row
1748
+
1749
+                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
1750
+                        .font(yAxisFont)
1751
+                        .monospacedDigit()
1752
+                        .lineLimit(1)
1753
+                        .minimumScaleFactor(0.8)
1754
+                        .frame(width: max(geometry.size.width - 10, 0))
1755
+                        .position(
1756
+                            x: geometry.size.width / 2,
1757
+                            y: topInset + yGuidePosition(
1758
+                                for: labelIndex,
1759
+                                context: context,
1760
+                                height: labelAreaHeight
1761
+                            )
1762
+                        )
1763
+                }
1764
+
1765
+                Text(measurementUnit)
1766
+                    .font(unitFont)
1767
+                    .foregroundColor(tint)
1768
+                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1769
+                    .padding(.vertical, isLargeDisplay ? 5 : 4)
1770
+                    .background(
1771
+                        Capsule(style: .continuous)
1772
+                            .fill(tint.opacity(0.14))
1773
+                    )
1774
+                    .padding(.top, 8)
1775
+
1776
+            }
1777
+        }
1778
+        .frame(height: height)
1779
+        .background(
1780
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
1781
+                .fill(tint.opacity(0.12))
1782
+        )
1783
+        .overlay(
1784
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
1785
+                .stroke(tint.opacity(0.20), lineWidth: 1)
1786
+        )
1787
+        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1788
+        .gesture(
1789
+            DragGesture(minimumDistance: 0)
1790
+                .onEnded { value in
1791
+                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
1792
+                }
1793
+        )
1794
+    }
1795
+    
1796
+    fileprivate func horizontalGuides(context: ChartContext) -> some View {
1797
+        GeometryReader { geometry in
1798
+            Path { path in
1799
+                for labelIndex in 1...self.yLabels {
1800
+                    let y = yGuidePosition(
1801
+                        for: labelIndex,
1802
+                        context: context,
1803
+                        height: geometry.size.height
1804
+                    )
1805
+                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
1806
+                }
1807
+            }
1808
+            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
1809
+        }
1810
+    }
1811
+    
1812
+    fileprivate func verticalGuides(context: ChartContext) -> some View {
1813
+        GeometryReader { geometry in
1814
+            Path { path in
1815
+                
1816
+                for labelIndex in 2..<self.xLabels {
1817
+                    let x = xGuidePosition(
1818
+                        for: labelIndex,
1819
+                        context: context,
1820
+                        width: geometry.size.width
1821
+                    )
1822
+                    path.move(to: CGPoint(x: x, y: 0) )
1823
+                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1824
+                }
1825
+            }
1826
+            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
1827
+        }
1828
+    }
1829
+
1830
+    fileprivate func discontinuityMarkers(
1831
+        points: [Measurements.Measurement.Point],
1832
+        context: ChartContext
1833
+    ) -> some View {
1834
+        GeometryReader { geometry in
1835
+            Path { path in
1836
+                for point in points where point.isDiscontinuity {
1837
+                    let markerX = context.placeInRect(
1838
+                        point: CGPoint(
1839
+                            x: point.timestamp.timeIntervalSince1970,
1840
+                            y: context.origin.y
1841
+                        )
1842
+                    ).x * geometry.size.width
1843
+                    path.move(to: CGPoint(x: markerX, y: 0))
1844
+                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1845
+                }
1846
+            }
1847
+            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1848
+        }
1849
+    }
1850
+    
1851
+}
1852
+
1853
+private struct TimeRangeSelectorView: View {
1854
+    private enum DragTarget {
1855
+        case lowerBound
1856
+        case upperBound
1857
+        case window
1858
+    }
1859
+
1860
+    private enum ActionTone {
1861
+        case reversible
1862
+        case destructive
1863
+        case destructiveProminent
1864
+    }
1865
+
1866
+    private struct DragState {
1867
+        let target: DragTarget
1868
+        let initialRange: ClosedRange<Date>
1869
+    }
1870
+
1871
+    let points: [Measurements.Measurement.Point]
1872
+    let context: ChartContext
1873
+    let availableTimeRange: ClosedRange<Date>
1874
+    let selectorTint: Color
1875
+    let compactLayout: Bool
1876
+    let minimumSelectionSpan: TimeInterval
1877
+    let onKeepSelection: (ClosedRange<Date>) -> Void
1878
+    let onRemoveSelection: (ClosedRange<Date>) -> Void
1879
+    let onResetBuffer: () -> Void
1880
+
1881
+    @Binding var selectedTimeRange: ClosedRange<Date>?
1882
+    @Binding var isPinnedToPresent: Bool
1883
+    @Binding var presentTrackingMode: PresentTrackingMode
1884
+    @State private var dragState: DragState?
1885
+    @State private var showResetConfirmation: Bool = false
1886
+
1887
+    private var totalSpan: TimeInterval {
1888
+        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1889
+    }
1890
+
1891
+    private var currentRange: ClosedRange<Date> {
1892
+        resolvedSelectionRange()
1893
+    }
1894
+
1895
+    private var trackHeight: CGFloat {
1896
+        compactLayout ? 72 : 86
1897
+    }
1898
+
1899
+    private var cornerRadius: CGFloat {
1900
+        compactLayout ? 14 : 16
1901
+    }
1902
+
1903
+    private var boundaryFont: Font {
1904
+        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1905
+    }
1906
+
1907
+    private var symbolButtonSize: CGFloat {
1908
+        compactLayout ? 28 : 32
1909
+    }
1910
+
1911
+    var body: some View {
1912
+        let coversFullRange = selectionCoversFullRange(currentRange)
1913
+
1914
+        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1915
+            if !coversFullRange || isPinnedToPresent {
1916
+                HStack(spacing: 8) {
1917
+                    alignmentButton(
1918
+                        systemName: "arrow.left.to.line.compact",
1919
+                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1920
+                        action: alignSelectionToLeadingEdge,
1921
+                        accessibilityLabel: "Align selection to start"
1922
+                    )
1923
+
1924
+                    alignmentButton(
1925
+                        systemName: "arrow.right.to.line.compact",
1926
+                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1927
+                        action: alignSelectionToTrailingEdge,
1928
+                        accessibilityLabel: "Align selection to present"
1929
+                    )
1930
+
1931
+                    Spacer(minLength: 0)
1932
+
1933
+                    if isPinnedToPresent {
1934
+                        trackingModeToggleButton()
1935
+                    }
1936
+                }
1937
+            }
1938
+
1939
+            HStack(spacing: 8) {
1940
+                if !coversFullRange {
1941
+                    actionButton(
1942
+                        title: compactLayout ? "Keep" : "Keep Selection",
1943
+                        systemName: "scissors",
1944
+                        tone: .destructive,
1945
+                        action: {
1946
+                            onKeepSelection(currentRange)
1947
+                        }
1948
+                    )
1949
+
1950
+                    actionButton(
1951
+                        title: compactLayout ? "Cut" : "Remove Selection",
1952
+                        systemName: "minus.circle",
1953
+                        tone: .destructive,
1954
+                        action: {
1955
+                            onRemoveSelection(currentRange)
1956
+                        }
1957
+                    )
1958
+                }
1959
+
1960
+                Spacer(minLength: 0)
1961
+
1962
+                actionButton(
1963
+                    title: compactLayout ? "Reset" : "Reset Buffer",
1964
+                    systemName: "trash",
1965
+                    tone: .destructiveProminent,
1966
+                    action: {
1967
+                        showResetConfirmation = true
1968
+                    }
1969
+                )
1970
+            }
1971
+            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
1972
+                Button("Reset buffer", role: .destructive) {
1973
+                    onResetBuffer()
1974
+                }
1975
+                Button("Cancel", role: .cancel) {}
1976
+            }
1977
+
1978
+            GeometryReader { geometry in
1979
+                let selectionFrame = selectionFrame(in: geometry.size)
1980
+                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
1981
+
1982
+                ZStack(alignment: .topLeading) {
1983
+                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1984
+                        .fill(Color.primary.opacity(0.05))
1985
+
1986
+                    Chart(
1987
+                        points: points,
1988
+                        context: context,
1989
+                        areaChart: true,
1990
+                        strokeColor: selectorTint,
1991
+                        areaFillColor: selectorTint.opacity(0.22)
1992
+                    )
1993
+                    .opacity(0.94)
1994
+                    .allowsHitTesting(false)
1995
+
1996
+                    Chart(
1997
+                        points: points,
1998
+                        context: context,
1999
+                        strokeColor: selectorTint.opacity(0.56)
2000
+                    )
2001
+                    .opacity(0.82)
2002
+                    .allowsHitTesting(false)
2003
+
2004
+                    if selectionFrame.minX > 0 {
2005
+                        Rectangle()
2006
+                            .fill(dimmingColor)
2007
+                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2008
+                            .allowsHitTesting(false)
2009
+                    }
2010
+
2011
+                    if selectionFrame.maxX < geometry.size.width {
2012
+                        Rectangle()
2013
+                            .fill(dimmingColor)
2014
+                            .frame(
2015
+                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2016
+                                height: geometry.size.height
2017
+                            )
2018
+                            .offset(x: selectionFrame.maxX)
2019
+                            .allowsHitTesting(false)
2020
+                    }
2021
+
2022
+                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
2023
+                        .fill(selectorTint.opacity(0.18))
2024
+                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2025
+                        .offset(x: selectionFrame.minX)
2026
+                        .allowsHitTesting(false)
2027
+
2028
+                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
2029
+                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
2030
+                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2031
+                        .offset(x: selectionFrame.minX)
2032
+                        .allowsHitTesting(false)
2033
+
2034
+                    handleView(height: max(geometry.size.height - 18, 16))
2035
+                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2036
+                        .allowsHitTesting(false)
2037
+
2038
+                    handleView(height: max(geometry.size.height - 18, 16))
2039
+                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2040
+                        .allowsHitTesting(false)
2041
+                }
2042
+                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2043
+                .overlay(
2044
+                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2045
+                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2046
+                )
2047
+                .contentShape(Rectangle())
2048
+                .gesture(selectionGesture(totalWidth: geometry.size.width))
2049
+            }
2050
+            .frame(height: trackHeight)
2051
+
2052
+            HStack {
2053
+                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2054
+                Spacer(minLength: 0)
2055
+                Text(boundaryLabel(for: availableTimeRange.upperBound))
2056
+            }
2057
+            .font(boundaryFont)
2058
+            .foregroundColor(.secondary)
2059
+            .monospacedDigit()
2060
+        }
2061
+    }
2062
+
2063
+    private func handleView(height: CGFloat) -> some View {
2064
+        Capsule(style: .continuous)
2065
+            .fill(Color.white.opacity(0.95))
2066
+            .frame(width: 6, height: height)
2067
+            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2068
+    }
2069
+
2070
+    private func alignmentButton(
2071
+        systemName: String,
2072
+        isActive: Bool,
2073
+        action: @escaping () -> Void,
2074
+        accessibilityLabel: String
2075
+    ) -> some View {
2076
+        Button(action: action) {
2077
+            Image(systemName: systemName)
2078
+                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2079
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
2080
+        }
2081
+        .buttonStyle(.plain)
2082
+        .foregroundColor(isActive ? .white : selectorTint)
2083
+        .background(
2084
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
2085
+                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
2086
+        )
2087
+        .overlay(
2088
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
2089
+                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
2090
+        )
2091
+        .accessibilityLabel(accessibilityLabel)
2092
+    }
2093
+
2094
+    private func trackingModeToggleButton() -> some View {
2095
+        Button {
2096
+            presentTrackingMode = presentTrackingMode == .keepDuration
2097
+                ? .keepStartTimestamp
2098
+                : .keepDuration
2099
+        } label: {
2100
+            Image(systemName: trackingModeSymbolName)
2101
+                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2102
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
2103
+        }
2104
+        .buttonStyle(.plain)
2105
+        .foregroundColor(.white)
2106
+        .background(
2107
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
2108
+                .fill(selectorTint)
2109
+        )
2110
+        .overlay(
2111
+            RoundedRectangle(cornerRadius: 9, style: .continuous)
2112
+                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
2113
+        )
2114
+        .accessibilityLabel(trackingModeAccessibilityLabel)
2115
+        .accessibilityHint("Toggles how the interval follows the present")
2116
+    }
2117
+
2118
+    private func actionButton(
2119
+        title: String,
2120
+        systemName: String,
2121
+        tone: ActionTone,
2122
+        action: @escaping () -> Void
2123
+    ) -> some View {
2124
+        let foregroundColor: Color = {
2125
+            switch tone {
2126
+            case .reversible, .destructive:
2127
+                return toneColor(for: tone)
2128
+            case .destructiveProminent:
2129
+                return .white
2130
+            }
2131
+        }()
2132
+
2133
+        return Button(action: action) {
2134
+            Label(title, systemImage: systemName)
2135
+                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2136
+                .padding(.horizontal, compactLayout ? 10 : 12)
2137
+                .padding(.vertical, compactLayout ? 7 : 8)
2138
+        }
2139
+        .buttonStyle(.plain)
2140
+        .foregroundColor(foregroundColor)
2141
+        .background(
2142
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
2143
+                .fill(actionButtonBackground(for: tone))
2144
+        )
2145
+        .overlay(
2146
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
2147
+                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2148
+        )
2149
+    }
2150
+
2151
+    private func toneColor(for tone: ActionTone) -> Color {
2152
+        switch tone {
2153
+        case .reversible:
2154
+            return selectorTint
2155
+        case .destructive, .destructiveProminent:
2156
+            return .red
2157
+        }
2158
+    }
2159
+
2160
+    private func actionButtonBackground(for tone: ActionTone) -> Color {
2161
+        switch tone {
2162
+        case .reversible:
2163
+            return selectorTint.opacity(0.12)
2164
+        case .destructive:
2165
+            return Color.red.opacity(0.12)
2166
+        case .destructiveProminent:
2167
+            return Color.red.opacity(0.82)
2168
+        }
2169
+    }
2170
+
2171
+    private func actionButtonBorder(for tone: ActionTone) -> Color {
2172
+        switch tone {
2173
+        case .reversible:
2174
+            return selectorTint.opacity(0.22)
2175
+        case .destructive:
2176
+            return Color.red.opacity(0.22)
2177
+        case .destructiveProminent:
2178
+            return Color.red.opacity(0.72)
2179
+        }
2180
+    }
2181
+
2182
+    private var trackingModeSymbolName: String {
2183
+        switch presentTrackingMode {
2184
+        case .keepDuration:
2185
+            return "arrow.left.and.right"
2186
+        case .keepStartTimestamp:
2187
+            return "arrow.left.to.line.compact"
2188
+        }
2189
+    }
2190
+
2191
+    private var trackingModeAccessibilityLabel: String {
2192
+        switch presentTrackingMode {
2193
+        case .keepDuration:
2194
+            return "Follow present keeping span"
2195
+        case .keepStartTimestamp:
2196
+            return "Follow present keeping start"
2197
+        }
2198
+    }
2199
+
2200
+    private func alignSelectionToLeadingEdge() {
2201
+        let alignedRange = normalizedSelectionRange(
2202
+            availableTimeRange.lowerBound...currentRange.upperBound
2203
+        )
2204
+        applySelection(alignedRange, pinToPresent: false)
2205
+    }
2206
+
2207
+    private func alignSelectionToTrailingEdge() {
2208
+        let alignedRange = normalizedSelectionRange(
2209
+            currentRange.lowerBound...availableTimeRange.upperBound
2210
+        )
2211
+        applySelection(alignedRange, pinToPresent: true)
2212
+    }
2213
+
2214
+    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2215
+        DragGesture(minimumDistance: 0)
2216
+            .onChanged { value in
2217
+                updateSelectionDrag(value: value, totalWidth: totalWidth)
2218
+            }
2219
+            .onEnded { _ in
2220
+                dragState = nil
2221
+            }
2222
+    }
2223
+
2224
+    private func updateSelectionDrag(
2225
+        value: DragGesture.Value,
2226
+        totalWidth: CGFloat
2227
+    ) {
2228
+        let startingRange = resolvedSelectionRange()
2229
+
2230
+        if dragState == nil {
2231
+            dragState = DragState(
2232
+                target: dragTarget(
2233
+                    for: value.startLocation.x,
2234
+                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2235
+                ),
2236
+                initialRange: startingRange
2237
+            )
2238
+        }
2239
+
2240
+        guard let dragState else { return }
2241
+
2242
+        let resultingRange = snappedToEdges(
2243
+            adjustedRange(
2244
+                from: dragState.initialRange,
2245
+                target: dragState.target,
2246
+                translationX: value.translation.width,
2247
+                totalWidth: totalWidth
2248
+            ),
2249
+            target: dragState.target,
2250
+            totalWidth: totalWidth
2251
+        )
2252
+
2253
+        applySelection(
2254
+            resultingRange,
2255
+            pinToPresent: shouldKeepPresentPin(
2256
+                during: dragState.target,
2257
+                initialRange: dragState.initialRange,
2258
+                resultingRange: resultingRange
2259
+            ),
2260
+        )
2261
+    }
2262
+
2263
+    private func dragTarget(
2264
+        for startX: CGFloat,
2265
+        selectionFrame: CGRect
2266
+    ) -> DragTarget {
2267
+        let handleZone: CGFloat = compactLayout ? 20 : 24
2268
+
2269
+        if abs(startX - selectionFrame.minX) <= handleZone {
2270
+            return .lowerBound
2271
+        }
2272
+
2273
+        if abs(startX - selectionFrame.maxX) <= handleZone {
2274
+            return .upperBound
2275
+        }
2276
+
2277
+        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2278
+            return .window
2279
+        }
2280
+
2281
+        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2282
+    }
2283
+
2284
+    private func adjustedRange(
2285
+        from initialRange: ClosedRange<Date>,
2286
+        target: DragTarget,
2287
+        translationX: CGFloat,
2288
+        totalWidth: CGFloat
2289
+    ) -> ClosedRange<Date> {
2290
+        guard totalSpan > 0, totalWidth > 0 else {
2291
+            return availableTimeRange
2292
+        }
2293
+
2294
+        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2295
+        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2296
+
2297
+        switch target {
2298
+        case .lowerBound:
2299
+            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2300
+            let newLowerBound = min(
2301
+                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2302
+                maximumLowerBound
2303
+            )
2304
+            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2305
+
2306
+        case .upperBound:
2307
+            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2308
+            let newUpperBound = max(
2309
+                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2310
+                minimumUpperBound
2311
+            )
2312
+            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2313
+
2314
+        case .window:
2315
+            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2316
+            guard span < totalSpan else { return availableTimeRange }
2317
+
2318
+            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2319
+            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2320
+
2321
+            if lowerBound < availableTimeRange.lowerBound {
2322
+                upperBound = upperBound.addingTimeInterval(
2323
+                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2324
+                )
2325
+                lowerBound = availableTimeRange.lowerBound
2326
+            }
2327
+
2328
+            if upperBound > availableTimeRange.upperBound {
2329
+                lowerBound = lowerBound.addingTimeInterval(
2330
+                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2331
+                )
2332
+                upperBound = availableTimeRange.upperBound
2333
+            }
2334
+
2335
+            return normalizedSelectionRange(lowerBound...upperBound)
2336
+        }
2337
+    }
2338
+
2339
+    private func snappedToEdges(
2340
+        _ candidateRange: ClosedRange<Date>,
2341
+        target: DragTarget,
2342
+        totalWidth: CGFloat
2343
+    ) -> ClosedRange<Date> {
2344
+        guard totalSpan > 0 else {
2345
+            return availableTimeRange
2346
+        }
2347
+
2348
+        let snapInterval = edgeSnapInterval(for: totalWidth)
2349
+        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2350
+        var lowerBound = candidateRange.lowerBound
2351
+        var upperBound = candidateRange.upperBound
2352
+
2353
+        if target != .upperBound,
2354
+           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2355
+            lowerBound = availableTimeRange.lowerBound
2356
+            if target == .window {
2357
+                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2358
+            }
2359
+        }
2360
+
2361
+        if target != .lowerBound,
2362
+           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2363
+            upperBound = availableTimeRange.upperBound
2364
+            if target == .window {
2365
+                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2366
+            }
2367
+        }
2368
+
2369
+        return normalizedSelectionRange(lowerBound...upperBound)
2370
+    }
2371
+
2372
+    private func edgeSnapInterval(
2373
+        for totalWidth: CGFloat
2374
+    ) -> TimeInterval {
2375
+        guard totalWidth > 0 else { return minimumSelectionSpan }
2376
+
2377
+        let snapWidth = min(
2378
+            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2379
+            totalWidth * 0.18
2380
+        )
2381
+        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2382
+        return min(
2383
+            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2384
+            totalSpan / 4
2385
+        )
2386
+    }
2387
+
2388
+    private func resolvedSelectionRange() -> ClosedRange<Date> {
2389
+        guard let selectedTimeRange else { return availableTimeRange }
2390
+
2391
+        if isPinnedToPresent {
2392
+            switch presentTrackingMode {
2393
+            case .keepDuration:
2394
+                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2395
+                return normalizedSelectionRange(
2396
+                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2397
+                )
2398
+            case .keepStartTimestamp:
2399
+                return normalizedSelectionRange(
2400
+                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2401
+                )
2402
+            }
2403
+        }
2404
+
2405
+        return normalizedSelectionRange(selectedTimeRange)
2406
+    }
2407
+
2408
+    private func normalizedSelectionRange(
2409
+        _ candidateRange: ClosedRange<Date>
2410
+    ) -> ClosedRange<Date> {
2411
+        let availableSpan = totalSpan
2412
+        guard availableSpan > 0 else { return availableTimeRange }
2413
+
2414
+        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2415
+        let requestedSpan = min(
2416
+            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2417
+            availableSpan
2418
+        )
2419
+
2420
+        if requestedSpan >= availableSpan {
2421
+            return availableTimeRange
2422
+        }
2423
+
2424
+        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2425
+        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2426
+
2427
+        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2428
+            if lowerBound == availableTimeRange.lowerBound {
2429
+                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2430
+            } else {
2431
+                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2432
+            }
2433
+        }
2434
+
2435
+        if upperBound > availableTimeRange.upperBound {
2436
+            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2437
+            upperBound = availableTimeRange.upperBound
2438
+            lowerBound = lowerBound.addingTimeInterval(-delta)
2439
+        }
2440
+
2441
+        if lowerBound < availableTimeRange.lowerBound {
2442
+            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2443
+            lowerBound = availableTimeRange.lowerBound
2444
+            upperBound = upperBound.addingTimeInterval(delta)
2445
+        }
2446
+
2447
+        return lowerBound...upperBound
2448
+    }
2449
+
2450
+    private func shouldKeepPresentPin(
2451
+        during target: DragTarget,
2452
+        initialRange: ClosedRange<Date>,
2453
+        resultingRange: ClosedRange<Date>
2454
+    ) -> Bool {
2455
+        let startedPinnedToPresent =
2456
+            isPinnedToPresent ||
2457
+            selectionCoversFullRange(initialRange)
2458
+
2459
+        guard startedPinnedToPresent else {
2460
+            return selectionTouchesPresent(resultingRange)
2461
+        }
2462
+
2463
+        switch target {
2464
+        case .lowerBound:
2465
+            return true
2466
+        case .upperBound, .window:
2467
+            return selectionTouchesPresent(resultingRange)
2468
+        }
2469
+    }
2470
+
2471
+    private func applySelection(
2472
+        _ candidateRange: ClosedRange<Date>,
2473
+        pinToPresent: Bool
2474
+    ) {
2475
+        let normalizedRange = normalizedSelectionRange(candidateRange)
2476
+
2477
+        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2478
+            selectedTimeRange = nil
2479
+        } else {
2480
+            selectedTimeRange = normalizedRange
2481
+        }
2482
+
2483
+        isPinnedToPresent = pinToPresent
2484
+    }
2485
+
2486
+    private func selectionTouchesPresent(
2487
+        _ range: ClosedRange<Date>
2488
+    ) -> Bool {
2489
+        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2490
+        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2491
+    }
2492
+
2493
+    private func selectionCoversFullRange(
2494
+        _ range: ClosedRange<Date>
2495
+    ) -> Bool {
2496
+        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2497
+        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2498
+        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2499
+    }
2500
+
2501
+    private func selectionFrame(in size: CGSize) -> CGRect {
2502
+        selectionFrame(for: currentRange, width: size.width)
2503
+    }
2504
+
2505
+    private func selectionFrame(
2506
+        for range: ClosedRange<Date>,
2507
+        width: CGFloat
2508
+    ) -> CGRect {
2509
+        guard width > 0, totalSpan > 0 else {
2510
+            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2511
+        }
2512
+
2513
+        let minimumX = xPosition(for: range.lowerBound, width: width)
2514
+        let maximumX = xPosition(for: range.upperBound, width: width)
2515
+        return CGRect(
2516
+            x: minimumX,
2517
+            y: 0,
2518
+            width: max(maximumX - minimumX, 2),
2519
+            height: trackHeight
2520
+        )
2521
+    }
2522
+
2523
+    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2524
+        guard width > 0, totalSpan > 0 else { return 0 }
2525
+
2526
+        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2527
+        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2528
+        return CGFloat(normalizedOffset) * width
2529
+    }
2530
+
2531
+    private func boundaryLabel(for date: Date) -> String {
2532
+        date.format(as: boundaryDateFormat)
2533
+    }
2534
+
2535
+    private var boundaryDateFormat: String {
2536
+        switch totalSpan {
2537
+        case 0..<86400:
2538
+            return "HH:mm"
2539
+        case 86400..<604800:
2540
+            return "MMM d HH:mm"
2541
+        default:
2542
+            return "MMM d"
2543
+        }
2544
+    }
2545
+}
2546
+
2547
+struct Chart : View {
2548
+    
2549
+    @Environment(\.displayScale) private var displayScale
2550
+
2551
+    let points: [Measurements.Measurement.Point]
2552
+    let context: ChartContext
2553
+    var areaChart: Bool = false
2554
+    var strokeColor: Color = .black
2555
+    var areaFillColor: Color? = nil
2556
+    
2557
+    var body : some View {
2558
+        GeometryReader { geometry in
2559
+            if self.areaChart {
2560
+                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
2561
+                self.path( geometry: geometry )
2562
+                    .fill(
2563
+                        LinearGradient(
2564
+                            gradient: .init(
2565
+                                colors: [
2566
+                                    fillColor.opacity(0.72),
2567
+                                    fillColor.opacity(0.18)
2568
+                                ]
2569
+                            ),
2570
+                            startPoint: .init(x: 0.5, y: 0.08),
2571
+                            endPoint: .init(x: 0.5, y: 0.92)
2572
+                        )
2573
+                    )
2574
+            } else {
2575
+                self.path( geometry: geometry )
2576
+                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2577
+            }
2578
+        }
2579
+    }
2580
+    
2581
+    fileprivate func path(geometry: GeometryProxy) -> Path {
2582
+        let displayedPoints = scaledPoints(for: geometry.size.width)
2583
+        let baselineY = context.placeInRect(
2584
+            point: CGPoint(x: context.origin.x, y: context.origin.y)
2585
+        ).y * geometry.size.height
2586
+
2587
+        return Path { path in
2588
+            var firstRenderedPoint: CGPoint?
2589
+            var lastRenderedPoint: CGPoint?
2590
+            var needsMove = true
2591
+
2592
+            for point in displayedPoints {
2593
+                if point.isDiscontinuity {
2594
+                    closeAreaSegment(
2595
+                        in: &path,
2596
+                        firstPoint: firstRenderedPoint,
2597
+                        lastPoint: lastRenderedPoint,
2598
+                        baselineY: baselineY
2599
+                    )
2600
+                    firstRenderedPoint = nil
2601
+                    lastRenderedPoint = nil
2602
+                    needsMove = true
2603
+                    continue
2604
+                }
2605
+
2606
+                let item = context.placeInRect(point: point.point())
2607
+                let renderedPoint = CGPoint(
2608
+                    x: item.x * geometry.size.width,
2609
+                    y: item.y * geometry.size.height
2610
+                )
2611
+
2612
+                if needsMove {
2613
+                    path.move(to: renderedPoint)
2614
+                    firstRenderedPoint = renderedPoint
2615
+                    needsMove = false
2616
+                } else {
2617
+                    path.addLine(to: renderedPoint)
2618
+                }
2619
+
2620
+                lastRenderedPoint = renderedPoint
2621
+            }
2622
+
2623
+            closeAreaSegment(
2624
+                in: &path,
2625
+                firstPoint: firstRenderedPoint,
2626
+                lastPoint: lastRenderedPoint,
2627
+                baselineY: baselineY
2628
+            )
2629
+        }
2630
+    }
2631
+
2632
+    private func closeAreaSegment(
2633
+        in path: inout Path,
2634
+        firstPoint: CGPoint?,
2635
+        lastPoint: CGPoint?,
2636
+        baselineY: CGFloat
2637
+    ) {
2638
+        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2639
+
2640
+        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2641
+        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2642
+        path.closeSubpath()
2643
+    }
2644
+
2645
+    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2646
+        let sampleCount = points.reduce(into: 0) { partialResult, point in
2647
+            if point.isSample {
2648
+                partialResult += 1
2649
+            }
2650
+        }
2651
+
2652
+        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2653
+        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2654
+
2655
+        guard sampleCount > maximumSamplesToRender, context.isValid else {
2656
+            return points
2657
+        }
2658
+
2659
+        var scaledPoints: [Measurements.Measurement.Point] = []
2660
+        var currentSegment: [Measurements.Measurement.Point] = []
2661
+
2662
+        for point in points {
2663
+            if point.isDiscontinuity {
2664
+                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2665
+                currentSegment.removeAll(keepingCapacity: true)
2666
+
2667
+                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2668
+                    appendScaledPoint(point, to: &scaledPoints)
2669
+                }
2670
+            } else {
2671
+                currentSegment.append(point)
2672
+            }
2673
+        }
2674
+
2675
+        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2676
+        return scaledPoints.isEmpty ? points : scaledPoints
2677
+    }
2678
+
2679
+    private func appendScaledSegment(
2680
+        _ segment: [Measurements.Measurement.Point],
2681
+        to scaledPoints: inout [Measurements.Measurement.Point],
2682
+        displayColumns: Int
2683
+    ) {
2684
+        guard !segment.isEmpty else { return }
2685
+
2686
+        if segment.count <= max(displayColumns * 2, 120) {
2687
+            for point in segment {
2688
+                appendScaledPoint(point, to: &scaledPoints)
2689
+            }
2690
+            return
2691
+        }
2692
+
2693
+        var bucket: [Measurements.Measurement.Point] = []
2694
+        var currentColumn: Int?
2695
+
2696
+        for point in segment {
2697
+            let column = displayColumn(for: point, totalColumns: displayColumns)
2698
+
2699
+            if let currentColumn, currentColumn != column {
2700
+                appendBucket(bucket, to: &scaledPoints)
2701
+                bucket.removeAll(keepingCapacity: true)
2702
+            }
2703
+
2704
+            bucket.append(point)
2705
+            currentColumn = column
2706
+        }
2707
+
2708
+        appendBucket(bucket, to: &scaledPoints)
2709
+    }
2710
+
2711
+    private func appendBucket(
2712
+        _ bucket: [Measurements.Measurement.Point],
2713
+        to scaledPoints: inout [Measurements.Measurement.Point]
2714
+    ) {
2715
+        guard !bucket.isEmpty else { return }
2716
+
2717
+        if bucket.count <= 2 {
2718
+            for point in bucket {
2719
+                appendScaledPoint(point, to: &scaledPoints)
2720
+            }
2721
+            return
2722
+        }
2723
+
2724
+        let firstPoint = bucket.first!
2725
+        let lastPoint = bucket.last!
2726
+        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2727
+        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2728
+
2729
+        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2730
+            .sorted { lhs, rhs in
2731
+                if lhs.timestamp == rhs.timestamp {
2732
+                    return lhs.id < rhs.id
2733
+                }
2734
+                return lhs.timestamp < rhs.timestamp
2735
+            }
2736
+
2737
+        var emittedPointIDs: Set<Int> = []
2738
+        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2739
+            appendScaledPoint(point, to: &scaledPoints)
2740
+        }
2741
+    }
2742
+
2743
+    private func appendScaledPoint(
2744
+        _ point: Measurements.Measurement.Point,
2745
+        to scaledPoints: inout [Measurements.Measurement.Point]
2746
+    ) {
2747
+        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2748
+                scaledPoints.last?.value == point.value &&
2749
+                scaledPoints.last?.kind == point.kind) else {
2750
+            return
2751
+        }
2752
+
2753
+        scaledPoints.append(
2754
+            Measurements.Measurement.Point(
2755
+                id: scaledPoints.count,
2756
+                timestamp: point.timestamp,
2757
+                value: point.value,
2758
+                kind: point.kind
2759
+            )
2760
+        )
2761
+    }
2762
+
2763
+    private func displayColumn(
2764
+        for point: Measurements.Measurement.Point,
2765
+        totalColumns: Int
2766
+    ) -> Int {
2767
+        let totalColumns = max(totalColumns, 1)
2768
+        let timeSpan = max(Double(context.size.width), 1)
2769
+        let normalizedOffset = min(
2770
+            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2771
+            1
2772
+        )
2773
+
2774
+        return min(
2775
+            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2776
+            totalColumns - 1
2777
+        )
2778
+    }
2779
+    
2780
+}
+23 -0
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -0,0 +1,23 @@
1
+//
2
+//  MeterInfoCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterInfoCardView<Content: View>: View {
9
+    let title: String
10
+    let tint: Color
11
+    @ViewBuilder var content: Content
12
+
13
+    var body: some View {
14
+        VStack(alignment: .leading, spacing: 12) {
15
+            Text(title)
16
+                .font(.headline)
17
+            content
18
+        }
19
+        .frame(maxWidth: .infinity, alignment: .leading)
20
+        .padding(18)
21
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
22
+    }
23
+}
+22 -0
USB Meter/Views/Meter/Components/MeterInfoRowView.swift
@@ -0,0 +1,22 @@
1
+//
2
+//  MeterInfoRowView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterInfoRowView: View {
9
+    let label: String
10
+    let value: String
11
+
12
+    var body: some View {
13
+        HStack {
14
+            Text(label)
15
+            Spacer()
16
+            Text(value)
17
+                .foregroundColor(.secondary)
18
+                .multilineTextAlignment(.trailing)
19
+        }
20
+        .font(.footnote)
21
+    }
22
+}
+0 -298
USB Meter/Views/Meter/LiveView.swift
@@ -1,298 +0,0 @@
1
-//
2
-//  LiveView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 09/03/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import SwiftUI
10
-
11
-struct LiveView: View {
12
-    private struct MetricRange {
13
-        let minLabel: String
14
-        let maxLabel: String
15
-        let minValue: String
16
-        let maxValue: String
17
-    }
18
-
19
-    private struct LoadResistanceSymbol: View {
20
-        let color: Color
21
-
22
-        var body: some View {
23
-            GeometryReader { proxy in
24
-                let width = proxy.size.width
25
-                let height = proxy.size.height
26
-                let midY = height / 2
27
-                let startX = width * 0.10
28
-                let endX = width * 0.90
29
-                let boxMinX = width * 0.28
30
-                let boxMaxX = width * 0.72
31
-                let boxHeight = height * 0.34
32
-                let boxRect = CGRect(
33
-                    x: boxMinX,
34
-                    y: midY - (boxHeight / 2),
35
-                    width: boxMaxX - boxMinX,
36
-                    height: boxHeight
37
-                )
38
-                let strokeWidth = max(1.2, height * 0.055)
39
-
40
-                ZStack {
41
-                    Path { path in
42
-                        path.move(to: CGPoint(x: startX, y: midY))
43
-                        path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
44
-                        path.move(to: CGPoint(x: boxRect.maxX, y: midY))
45
-                        path.addLine(to: CGPoint(x: endX, y: midY))
46
-                    }
47
-                    .stroke(
48
-                        color,
49
-                        style: StrokeStyle(
50
-                            lineWidth: strokeWidth,
51
-                            lineCap: .round,
52
-                            lineJoin: .round
53
-                        )
54
-                    )
55
-
56
-                    Path { path in
57
-                        path.addRect(boxRect)
58
-                    }
59
-                    .stroke(
60
-                        color,
61
-                        style: StrokeStyle(
62
-                            lineWidth: strokeWidth,
63
-                            lineCap: .round,
64
-                            lineJoin: .round
65
-                        )
66
-                    )
67
-                }
68
-            }
69
-            .padding(4)
70
-        }
71
-    }
72
-    
73
-    @EnvironmentObject private var meter: Meter
74
-    var compactLayout: Bool = false
75
-    var availableSize: CGSize? = nil
76
-
77
-    var body: some View {
78
-        VStack(alignment: .leading, spacing: 16) {
79
-            HStack {
80
-                Text("Live Data")
81
-                    .font(.headline)
82
-                Spacer()
83
-                statusBadge
84
-            }
85
-
86
-            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
87
-                liveMetricCard(
88
-                    title: "Voltage",
89
-                    symbol: "bolt.fill",
90
-                    color: .green,
91
-                    value: "\(meter.voltage.format(decimalDigits: 3)) V",
92
-                    range: metricRange(
93
-                        min: meter.measurements.voltage.context.minValue,
94
-                        max: meter.measurements.voltage.context.maxValue,
95
-                        unit: "V"
96
-                    )
97
-                )
98
-
99
-                liveMetricCard(
100
-                    title: "Current",
101
-                    symbol: "waveform.path.ecg",
102
-                    color: .blue,
103
-                    value: "\(meter.current.format(decimalDigits: 3)) A",
104
-                    range: metricRange(
105
-                        min: meter.measurements.current.context.minValue,
106
-                        max: meter.measurements.current.context.maxValue,
107
-                        unit: "A"
108
-                    )
109
-                )
110
-
111
-                liveMetricCard(
112
-                    title: "Power",
113
-                    symbol: "flame.fill",
114
-                    color: .pink,
115
-                    value: "\(meter.power.format(decimalDigits: 3)) W",
116
-                    range: metricRange(
117
-                        min: meter.measurements.power.context.minValue,
118
-                        max: meter.measurements.power.context.maxValue,
119
-                        unit: "W"
120
-                    )
121
-                )
122
-
123
-                liveMetricCard(
124
-                    title: "Temperature",
125
-                    symbol: "thermometer.medium",
126
-                    color: .orange,
127
-                    value: meter.primaryTemperatureDescription,
128
-                    range: temperatureRange()
129
-                )
130
-
131
-                liveMetricCard(
132
-                    title: "Load",
133
-                    customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)),
134
-                    color: .yellow,
135
-                    value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
136
-                    detailText: "Measured resistance"
137
-                )
138
-
139
-                liveMetricCard(
140
-                    title: "RSSI",
141
-                    symbol: "dot.radiowaves.left.and.right",
142
-                    color: .mint,
143
-                    value: "\(meter.btSerial.averageRSSI) dBm",
144
-                    range: MetricRange(
145
-                        minLabel: "Min",
146
-                        maxLabel: "Max",
147
-                        minValue: "\(meter.btSerial.minRSSI) dBm",
148
-                        maxValue: "\(meter.btSerial.maxRSSI) dBm"
149
-                    ),
150
-                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold)
151
-                )
152
-            }
153
-        }
154
-        .frame(maxWidth: .infinity, alignment: .topLeading)
155
-    }
156
-
157
-    private var liveMetricColumns: [GridItem] {
158
-        if compactLayout {
159
-            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
160
-        }
161
-
162
-        return [GridItem(.flexible()), GridItem(.flexible())]
163
-    }
164
-
165
-    private var statusBadge: some View {
166
-        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
167
-            .font(.caption.weight(.semibold))
168
-            .padding(.horizontal, 10)
169
-            .padding(.vertical, 6)
170
-            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
171
-            .meterCard(
172
-                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
173
-                fillOpacity: 0.12,
174
-                strokeOpacity: 0.16,
175
-                cornerRadius: 999
176
-            )
177
-    }
178
-
179
-    private var showsCompactMetricRange: Bool {
180
-        compactLayout && (availableSize?.height ?? 0) >= 380
181
-    }
182
-
183
-    private var shouldShowMetricRange: Bool {
184
-        !compactLayout || showsCompactMetricRange
185
-    }
186
-
187
-    private func liveMetricCard(
188
-        title: String,
189
-        symbol: String? = nil,
190
-        customSymbol: AnyView? = nil,
191
-        color: Color,
192
-        value: String,
193
-        range: MetricRange? = nil,
194
-        detailText: String? = nil,
195
-        valueFont: Font? = nil,
196
-        valueLineLimit: Int = 1,
197
-        valueMonospacedDigits: Bool = true,
198
-        valueMinimumScaleFactor: CGFloat = 0.85
199
-    ) -> some View {
200
-        VStack(alignment: .leading, spacing: 10) {
201
-            HStack(spacing: compactLayout ? 8 : 10) {
202
-                Group {
203
-                    if let customSymbol {
204
-                        customSymbol
205
-                    } else if let symbol {
206
-                        Image(systemName: symbol)
207
-                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
208
-                            .foregroundColor(color)
209
-                    }
210
-                }
211
-                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
212
-                .background(Circle().fill(color.opacity(0.12)))
213
-
214
-                Text(title)
215
-                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
216
-                    .foregroundColor(.secondary)
217
-                    .lineLimit(1)
218
-
219
-                Spacer(minLength: 0)
220
-            }
221
-
222
-            Group {
223
-                if valueMonospacedDigits {
224
-                    Text(value)
225
-                        .monospacedDigit()
226
-                } else {
227
-                    Text(value)
228
-                }
229
-            }
230
-            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
231
-            .lineLimit(valueLineLimit)
232
-            .minimumScaleFactor(valueMinimumScaleFactor)
233
-
234
-            if shouldShowMetricRange {
235
-                if let range {
236
-                    metricRangeTable(range)
237
-                } else if let detailText, !detailText.isEmpty {
238
-                    Text(detailText)
239
-                        .font(.caption)
240
-                        .foregroundColor(.secondary)
241
-                        .lineLimit(2)
242
-                }
243
-            }
244
-        }
245
-        .frame(
246
-            maxWidth: .infinity,
247
-            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
248
-            alignment: .leading
249
-        )
250
-        .padding(compactLayout ? 12 : 16)
251
-        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
252
-    }
253
-
254
-    private func metricRangeTable(_ range: MetricRange) -> some View {
255
-        VStack(alignment: .leading, spacing: 4) {
256
-            HStack(spacing: 12) {
257
-                Text(range.minLabel)
258
-                Spacer(minLength: 0)
259
-                Text(range.maxLabel)
260
-            }
261
-            .font(.caption2.weight(.semibold))
262
-            .foregroundColor(.secondary)
263
-
264
-            HStack(spacing: 12) {
265
-                Text(range.minValue)
266
-                    .monospacedDigit()
267
-                Spacer(minLength: 0)
268
-                Text(range.maxValue)
269
-                    .monospacedDigit()
270
-            }
271
-            .font(.caption.weight(.medium))
272
-            .foregroundColor(.primary)
273
-        }
274
-    }
275
-
276
-    private func metricRange(min: Double, max: Double, unit: String) -> MetricRange? {
277
-        guard min.isFinite, max.isFinite else { return nil }
278
-
279
-        return MetricRange(
280
-            minLabel: "Min",
281
-            maxLabel: "Max",
282
-            minValue: "\(min.format(decimalDigits: 3)) \(unit)",
283
-            maxValue: "\(max.format(decimalDigits: 3)) \(unit)"
284
-        )
285
-    }
286
-
287
-    private func temperatureRange() -> MetricRange? {
288
-        let value = meter.primaryTemperatureDescription
289
-        guard !value.isEmpty else { return nil }
290
-
291
-        return MetricRange(
292
-            minLabel: "Min",
293
-            maxLabel: "Max",
294
-            minValue: value,
295
-            maxValue: value
296
-        )
297
-    }
298
-}
+0 -457
USB Meter/Views/Meter/Measurements/Chart/MeasurementChartView.swift
@@ -1,457 +0,0 @@
1
-//
2
-//  MeasurementChartView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 06/05/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import SwiftUI
10
-
11
-struct MeasurementChartView: View {
12
-    private let minimumTimeSpan: TimeInterval = 1
13
-    private let minimumVoltageSpan = 0.5
14
-    private let minimumCurrentSpan = 0.5
15
-    private let minimumPowerSpan = 0.5
16
-    private let axisColumnWidth: CGFloat = 46
17
-    private let chartSectionSpacing: CGFloat = 8
18
-    private let xAxisHeight: CGFloat = 28
19
-    
20
-    @EnvironmentObject private var measurements: Measurements
21
-    var timeRange: ClosedRange<Date>? = nil
22
-    
23
-    @State var displayVoltage: Bool = false
24
-    @State var displayCurrent: Bool = false
25
-    @State var displayPower: Bool = true
26
-    let xLabels: Int = 4
27
-    let yLabels: Int = 4
28
-
29
-    var body: some View {
30
-        let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan)
31
-        let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan)
32
-        let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan)
33
-        let primarySeries = displayedPrimarySeries(
34
-            powerSeries: powerSeries,
35
-            voltageSeries: voltageSeries,
36
-            currentSeries: currentSeries
37
-        )
38
-
39
-        Group {
40
-            if let primarySeries {
41
-                VStack(alignment: .leading, spacing: 12) {
42
-                    chartToggleBar
43
-
44
-                    GeometryReader { geometry in
45
-                        let plotHeight = max(geometry.size.height - xAxisHeight, 140)
46
-
47
-                        VStack(spacing: 6) {
48
-                            HStack(spacing: chartSectionSpacing) {
49
-                                primaryAxisView(
50
-                                    height: plotHeight,
51
-                                    powerSeries: powerSeries,
52
-                                    voltageSeries: voltageSeries,
53
-                                    currentSeries: currentSeries
54
-                                )
55
-                                .frame(width: axisColumnWidth, height: plotHeight)
56
-
57
-                                ZStack {
58
-                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
59
-                                        .fill(Color.primary.opacity(0.05))
60
-
61
-                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
62
-                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
63
-
64
-                                    horizontalGuides(context: primarySeries.context)
65
-                                    verticalGuides(context: primarySeries.context)
66
-                                    renderedChart(
67
-                                        powerSeries: powerSeries,
68
-                                        voltageSeries: voltageSeries,
69
-                                        currentSeries: currentSeries
70
-                                    )
71
-                                }
72
-                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
73
-                                .frame(maxWidth: .infinity)
74
-                                .frame(height: plotHeight)
75
-
76
-                                secondaryAxisView(
77
-                                    height: plotHeight,
78
-                                    powerSeries: powerSeries,
79
-                                    voltageSeries: voltageSeries,
80
-                                    currentSeries: currentSeries
81
-                                )
82
-                                .frame(width: axisColumnWidth, height: plotHeight)
83
-                            }
84
-
85
-                            xAxisLabelsView(context: primarySeries.context)
86
-                            .frame(height: xAxisHeight)
87
-                        }
88
-                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
89
-                    }
90
-                }
91
-            } else {
92
-                VStack(alignment: .leading, spacing: 12) {
93
-                    chartToggleBar
94
-                    Text("Nothing to show!")
95
-                        .foregroundColor(.secondary)
96
-                }
97
-            }
98
-        }
99
-        .font(.footnote)
100
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
101
-    }
102
-
103
-    private var chartToggleBar: some View {
104
-        HStack(spacing: 8) {
105
-            Button(action: {
106
-                self.displayVoltage.toggle()
107
-                if self.displayVoltage {
108
-                    self.displayPower = false
109
-                }
110
-            }) { Text("Voltage") }
111
-            .asEnableFeatureButton(state: displayVoltage)
112
-
113
-            Button(action: {
114
-                self.displayCurrent.toggle()
115
-                if self.displayCurrent {
116
-                    self.displayPower = false
117
-                }
118
-            }) { Text("Current") }
119
-            .asEnableFeatureButton(state: displayCurrent)
120
-
121
-            Button(action: {
122
-                self.displayPower.toggle()
123
-                if self.displayPower {
124
-                    self.displayCurrent = false
125
-                    self.displayVoltage = false
126
-                }
127
-            }) { Text("Power") }
128
-            .asEnableFeatureButton(state: displayPower)
129
-        }
130
-        .frame(maxWidth: .infinity, alignment: .center)
131
-    }
132
-
133
-    @ViewBuilder
134
-    private func primaryAxisView(
135
-        height: CGFloat,
136
-        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
137
-        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
138
-        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
139
-    ) -> some View {
140
-        if displayPower {
141
-            yAxisLabelsView(
142
-                height: height,
143
-                context: powerSeries.context,
144
-                measurementUnit: "W",
145
-                tint: .red
146
-            )
147
-        } else if displayVoltage {
148
-            yAxisLabelsView(
149
-                height: height,
150
-                context: voltageSeries.context,
151
-                measurementUnit: "V",
152
-                tint: .green
153
-            )
154
-        } else if displayCurrent {
155
-            yAxisLabelsView(
156
-                height: height,
157
-                context: currentSeries.context,
158
-                measurementUnit: "A",
159
-                tint: .blue
160
-            )
161
-        }
162
-    }
163
-
164
-    @ViewBuilder
165
-    private func renderedChart(
166
-        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
167
-        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
168
-        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
169
-    ) -> some View {
170
-        if self.displayPower {
171
-            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
172
-                .opacity(0.72)
173
-        } else {
174
-            if self.displayVoltage {
175
-                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
176
-                    .opacity(0.78)
177
-            }
178
-            if self.displayCurrent {
179
-                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
180
-                    .opacity(0.78)
181
-            }
182
-        }
183
-    }
184
-
185
-    @ViewBuilder
186
-    private func secondaryAxisView(
187
-        height: CGFloat,
188
-        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
189
-        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
190
-        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
191
-    ) -> some View {
192
-        if displayVoltage && displayCurrent {
193
-            yAxisLabelsView(
194
-                height: height,
195
-                context: currentSeries.context,
196
-                measurementUnit: "A",
197
-                tint: .blue
198
-            )
199
-        } else {
200
-            primaryAxisView(
201
-                height: height,
202
-                powerSeries: powerSeries,
203
-                voltageSeries: voltageSeries,
204
-                currentSeries: currentSeries
205
-            )
206
-        }
207
-    }
208
-
209
-    private func displayedPrimarySeries(
210
-        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
211
-        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
212
-        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
213
-    ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
214
-        if displayPower {
215
-            return powerSeries.points.isEmpty ? nil : powerSeries
216
-        }
217
-        if displayVoltage {
218
-            return voltageSeries.points.isEmpty ? nil : voltageSeries
219
-        }
220
-        if displayCurrent {
221
-            return currentSeries.points.isEmpty ? nil : currentSeries
222
-        }
223
-        return nil
224
-    }
225
-
226
-    private func series(
227
-        for measurement: Measurements.Measurement,
228
-        minimumYSpan: Double
229
-    ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
230
-        let points = measurement.points.filter { point in
231
-            guard let timeRange else { return true }
232
-            return timeRange.contains(point.timestamp)
233
-        }
234
-        let context = ChartContext()
235
-        for point in points {
236
-            context.include(point: point.point())
237
-        }
238
-        if !points.isEmpty {
239
-            context.ensureMinimumSize(
240
-                width: CGFloat(minimumTimeSpan),
241
-                height: CGFloat(minimumYSpan)
242
-            )
243
-        }
244
-        return (points, context)
245
-    }
246
-
247
-    private func yGuidePosition(
248
-        for labelIndex: Int,
249
-        context: ChartContext,
250
-        height: CGFloat
251
-    ) -> CGFloat {
252
-        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
253
-        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
254
-        return context.placeInRect(point: anchorPoint).y * height
255
-    }
256
-
257
-    private func xGuidePosition(
258
-        for labelIndex: Int,
259
-        context: ChartContext,
260
-        width: CGFloat
261
-    ) -> CGFloat {
262
-        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
263
-        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
264
-        return context.placeInRect(point: anchorPoint).x * width
265
-    }
266
-    
267
-    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
268
-    fileprivate func xAxisLabelsView(
269
-        context: ChartContext
270
-    ) -> some View {
271
-        var timeFormat: String?
272
-        switch context.size.width {
273
-        case 0..<3600: timeFormat = "HH:mm:ss"
274
-        case 3600...86400: timeFormat = "HH:mm"
275
-        default: timeFormat = "E HH:mm"
276
-        }
277
-        let labels = (1...xLabels).map {
278
-            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
279
-        }
280
-
281
-        return HStack(spacing: chartSectionSpacing) {
282
-            Color.clear
283
-                .frame(width: axisColumnWidth)
284
-
285
-            GeometryReader { geometry in
286
-                let labelWidth = max(
287
-                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
288
-                    1
289
-                )
290
-
291
-                ZStack(alignment: .topLeading) {
292
-                    Path { path in
293
-                        for labelIndex in 1...self.xLabels {
294
-                            let x = xGuidePosition(
295
-                                for: labelIndex,
296
-                                context: context,
297
-                                width: geometry.size.width
298
-                            )
299
-                            path.move(to: CGPoint(x: x, y: 0))
300
-                            path.addLine(to: CGPoint(x: x, y: 6))
301
-                        }
302
-                    }
303
-                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
304
-
305
-                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
306
-                        let labelIndex = item.offset + 1
307
-                        let centerX = xGuidePosition(
308
-                            for: labelIndex,
309
-                            context: context,
310
-                            width: geometry.size.width
311
-                        )
312
-
313
-                        Text(item.element)
314
-                            .font(.caption.weight(.semibold))
315
-                            .monospacedDigit()
316
-                            .lineLimit(1)
317
-                            .minimumScaleFactor(0.68)
318
-                            .frame(width: labelWidth)
319
-                            .position(
320
-                                x: centerX,
321
-                                y: geometry.size.height * 0.7
322
-                            )
323
-                    }
324
-                }
325
-                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
326
-            }
327
-
328
-            Color.clear
329
-                .frame(width: axisColumnWidth)
330
-        }
331
-    }
332
-    
333
-    fileprivate func yAxisLabelsView(
334
-        height: CGFloat,
335
-        context: ChartContext,
336
-        measurementUnit: String,
337
-        tint: Color
338
-    ) -> some View {
339
-        GeometryReader { geometry in
340
-            ZStack(alignment: .top) {
341
-                ForEach(0..<yLabels, id: \.self) { row in
342
-                    let labelIndex = yLabels - row
343
-
344
-                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
345
-                        .font(.caption2.weight(.semibold))
346
-                        .monospacedDigit()
347
-                        .lineLimit(1)
348
-                        .minimumScaleFactor(0.72)
349
-                        .frame(width: max(geometry.size.width - 6, 0))
350
-                        .position(
351
-                            x: geometry.size.width / 2,
352
-                            y: yGuidePosition(
353
-                                for: labelIndex,
354
-                                context: context,
355
-                                height: geometry.size.height
356
-                            )
357
-                        )
358
-                }
359
-
360
-                Text(measurementUnit)
361
-                    .font(.caption2.weight(.bold))
362
-                    .foregroundColor(tint)
363
-                    .padding(.horizontal, 6)
364
-                    .padding(.vertical, 4)
365
-                    .background(
366
-                        Capsule(style: .continuous)
367
-                            .fill(tint.opacity(0.14))
368
-                    )
369
-                    .padding(.top, 6)
370
-            }
371
-        }
372
-        .frame(height: height)
373
-        .background(
374
-            RoundedRectangle(cornerRadius: 16, style: .continuous)
375
-                .fill(tint.opacity(0.12))
376
-        )
377
-        .overlay(
378
-            RoundedRectangle(cornerRadius: 16, style: .continuous)
379
-                .stroke(tint.opacity(0.20), lineWidth: 1)
380
-        )
381
-    }
382
-    
383
-    fileprivate func horizontalGuides(context: ChartContext) -> some View {
384
-        GeometryReader { geometry in
385
-            Path { path in
386
-                for labelIndex in 1...self.yLabels {
387
-                    let y = yGuidePosition(
388
-                        for: labelIndex,
389
-                        context: context,
390
-                        height: geometry.size.height
391
-                    )
392
-                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
393
-                }
394
-            }
395
-            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
396
-        }
397
-    }
398
-    
399
-    fileprivate func verticalGuides(context: ChartContext) -> some View {
400
-        GeometryReader { geometry in
401
-            Path { path in
402
-                
403
-                for labelIndex in 2..<self.xLabels {
404
-                    let x = xGuidePosition(
405
-                        for: labelIndex,
406
-                        context: context,
407
-                        width: geometry.size.width
408
-                    )
409
-                    path.move(to: CGPoint(x: x, y: 0) )
410
-                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
411
-                }
412
-            }
413
-            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
414
-        }
415
-    }
416
-    
417
-}
418
-
419
-struct Chart : View {
420
-    
421
-    let points: [Measurements.Measurement.Point]
422
-    let context: ChartContext
423
-    var areaChart: Bool = false
424
-    var strokeColor: Color = .black
425
-    
426
-    var body : some View {
427
-        GeometryReader { geometry in
428
-            if self.areaChart {
429
-                self.path( geometry: geometry )
430
-                    .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
431
-            } else {
432
-                self.path( geometry: geometry )
433
-                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
434
-            }
435
-        }
436
-    }
437
-    
438
-    fileprivate func path(geometry: GeometryProxy) -> Path {
439
-        return Path { path in
440
-            guard let first = points.first else { return }
441
-            let firstPoint = context.placeInRect(point: first.point())
442
-            path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) )
443
-            for item in points.map({ context.placeInRect(point: $0.point()) }) {
444
-                path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) )
445
-            }
446
-            if self.areaChart {
447
-                let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y ))
448
-                let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y ))
449
-                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
450
-                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
451
-                // MARK: Nu e nevoie. Fill inchide automat calea
452
-                // path.closeSubpath()
453
-            }
454
-        }
455
-    }
456
-    
457
-}
+0 -257
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -1,257 +0,0 @@
1
-//
2
-//  SettingsView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 14/03/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import SwiftUI
10
-
11
-struct MeterSettingsView: View {
12
-    
13
-    @EnvironmentObject private var meter: Meter
14
-    @Environment(\.dismiss) private var dismiss
15
-
16
-    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
17
-
18
-    @State private var editingName = false
19
-    @State private var editingScreenTimeout = false
20
-    @State private var editingScreenBrightness = false
21
-
22
-    var body: some View {
23
-        VStack(spacing: 0) {
24
-            if Self.isMacIPadApp {
25
-                macSettingsHeader
26
-            }
27
-            ScrollView {
28
-            VStack (spacing: 14) {
29
-                settingsCard(title: "Name", tint: meter.color) {
30
-                    HStack {
31
-                        Spacer()
32
-                        if !editingName {
33
-                            Text(meter.name)
34
-                                .foregroundColor(.secondary)
35
-                        }
36
-                        ChevronView(rotate: $editingName)
37
-                    }
38
-                    if editingName {
39
-                        EditNameView(editingName: $editingName, newName: meter.name)
40
-                    }
41
-                }
42
-
43
-                if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
44
-                    settingsCard(title: "Meter Temperature Unit", tint: .orange) {
45
-                        Text("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.")
46
-                            .font(.footnote)
47
-                            .foregroundColor(.secondary)
48
-                        Picker("", selection: $meter.tc66TemperatureUnitPreference) {
49
-                            ForEach(TemperatureUnitPreference.allCases) { unit in
50
-                                Text(unit.title).tag(unit)
51
-                            }
52
-                        }
53
-                        .pickerStyle(SegmentedPickerStyle())
54
-                    }
55
-                }
56
-
57
-                if meter.operationalState == .dataIsAvailable {
58
-                    settingsCard(
59
-                        title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
60
-                        tint: .indigo
61
-                    ) {
62
-                        if meter.reportsCurrentScreenIndex {
63
-                            Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
64
-                                .font(.footnote)
65
-                                .foregroundColor(.secondary)
66
-                        } else {
67
-                            Text("Use these controls when you want to switch device pages without crowding the main meter view.")
68
-                                .font(.footnote)
69
-                                .foregroundColor(.secondary)
70
-                        }
71
-
72
-                        ControlView(showsHeader: false)
73
-                    }
74
-                }
75
-
76
-                if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
77
-                    settingsCard(title: "Screen Timeout", tint: .purple) {
78
-                        HStack {
79
-                            Spacer()
80
-                            if !editingScreenTimeout {
81
-                                Text(meter.screenTimeout != 0 ? "\(meter.screenTimeout) Minutes" : "Off")
82
-                                    .foregroundColor(.secondary)
83
-                            }
84
-                            ChevronView(rotate: $editingScreenTimeout)
85
-                        }
86
-                        if editingScreenTimeout {
87
-                            EditScreenTimeoutView()
88
-                        }
89
-                    }
90
-
91
-                    settingsCard(title: "Screen Brightness", tint: .yellow) {
92
-                        HStack {
93
-                            Spacer()
94
-                            if !editingScreenBrightness {
95
-                                Text("\(meter.screenBrightness)")
96
-                                    .foregroundColor(.secondary)
97
-                            }
98
-                            ChevronView(rotate: $editingScreenBrightness)
99
-                        }
100
-                        if editingScreenBrightness {
101
-                            EditScreenBrightnessView()
102
-                        }
103
-                    }
104
-                }
105
-            }
106
-            .padding()
107
-        }
108
-        .background(
109
-            LinearGradient(
110
-                colors: [meter.color.opacity(0.14), Color.clear],
111
-                startPoint: .topLeading,
112
-                endPoint: .bottomTrailing
113
-            )
114
-            .ignoresSafeArea()
115
-        )
116
-        }
117
-        .modifier(IOSOnlySettingsNavBar(
118
-            apply: !Self.isMacIPadApp,
119
-            rssi: meter.btSerial.averageRSSI
120
-        ))
121
-    }
122
-
123
-    // MARK: - Custom navigation header for Designed-for-iPad on Mac
124
-
125
-    private var macSettingsHeader: some View {
126
-        HStack(spacing: 12) {
127
-            Button {
128
-                dismiss()
129
-            } label: {
130
-                HStack(spacing: 4) {
131
-                    Image(systemName: "chevron.left")
132
-                        .font(.body.weight(.semibold))
133
-                    Text("Back")
134
-                }
135
-                .foregroundColor(.accentColor)
136
-            }
137
-            .buttonStyle(.plain)
138
-
139
-            Text("Meter Settings")
140
-                .font(.headline)
141
-                .lineLimit(1)
142
-
143
-            Spacer()
144
-
145
-            if meter.operationalState > .notPresent {
146
-                RSSIView(RSSI: meter.btSerial.averageRSSI)
147
-                    .frame(width: 18, height: 18)
148
-            }
149
-        }
150
-        .padding(.horizontal, 16)
151
-        .padding(.vertical, 10)
152
-        .background(
153
-            Rectangle()
154
-                .fill(.ultraThinMaterial)
155
-                .ignoresSafeArea(edges: .top)
156
-        )
157
-        .overlay(alignment: .bottom) {
158
-            Rectangle()
159
-                .fill(Color.secondary.opacity(0.12))
160
-                .frame(height: 1)
161
-        }
162
-    }
163
-
164
-    private func settingsCard<Content: View>(
165
-        title: String,
166
-        tint: Color,
167
-        @ViewBuilder content: () -> Content
168
-    ) -> some View {
169
-        VStack(alignment: .leading, spacing: 12) {
170
-            Text(title)
171
-                .font(.headline)
172
-            content()
173
-        }
174
-        .padding(18)
175
-        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
176
-    }
177
-}
178
-
179
-struct EditNameView: View {
180
-    
181
-    @EnvironmentObject private var meter: Meter
182
-    
183
-    @Binding var editingName: Bool
184
-    @State var newName: String
185
-    
186
-    var body: some View {
187
-        TextField("Name", text: self.$newName, onCommit: {
188
-            self.meter.name = self.newName
189
-            self.editingName = false
190
-        })
191
-            .textFieldStyle(RoundedBorderTextFieldStyle())
192
-            .lineLimit(1)
193
-            .disableAutocorrection(true)
194
-            .multilineTextAlignment(.center)
195
-    }
196
-}
197
-
198
-struct EditScreenTimeoutView: View {
199
-    
200
-    @EnvironmentObject private var meter: Meter
201
-    
202
-    var body: some View {
203
-        Picker("", selection: self.$meter.screenTimeout ) {
204
-            Text("1").tag(1)
205
-            Text("2").tag(2)
206
-            Text("3").tag(3)
207
-            Text("4").tag(4)
208
-            Text("5").tag(5)
209
-            Text("6").tag(6)
210
-            Text("7").tag(7)
211
-            Text("8").tag(8)
212
-            Text("9").tag(9)
213
-            Text("Off").tag(0)
214
-        }
215
-        .pickerStyle( SegmentedPickerStyle() )
216
-    }
217
-}
218
-
219
-struct EditScreenBrightnessView: View {
220
-    
221
-    @EnvironmentObject private var meter: Meter
222
-    
223
-    var body: some View {
224
-        Picker("", selection: self.$meter.screenBrightness ) {
225
-            Text("0").tag(0)
226
-            Text("1").tag(1)
227
-            Text("2").tag(2)
228
-            Text("3").tag(3)
229
-            Text("4").tag(4)
230
-            Text("5").tag(5)
231
-        }
232
-        .pickerStyle( SegmentedPickerStyle() )
233
-    }
234
-}
235
-
236
-// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
237
-
238
-private struct IOSOnlySettingsNavBar: ViewModifier {
239
-    let apply: Bool
240
-    let rssi: Int
241
-
242
-    @ViewBuilder
243
-    func body(content: Content) -> some View {
244
-        if apply {
245
-            content
246
-                .navigationBarTitle("Meter Settings")
247
-                .toolbar {
248
-                    ToolbarItem(placement: .navigationBarTrailing) {
249
-                        RSSIView(RSSI: rssi).frame(width: 18, height: 18)
250
-                    }
251
-                }
252
-        } else {
253
-            content
254
-                .navigationBarHidden(true)
255
-        }
256
-    }
257
-}
+300 -483
USB Meter/Views/Meter/MeterView.swift
@@ -11,24 +11,102 @@ import SwiftUI
11 11
 import CoreBluetooth
12 12
 
13 13
 struct MeterView: View {
14
-    private enum MeterTab: Hashable {
15
-        case connection
14
+    private struct TabBarStyle {
15
+        let showsTitles: Bool
16
+        let horizontalPadding: CGFloat
17
+        let topPadding: CGFloat
18
+        let bottomPadding: CGFloat
19
+        let chipHorizontalPadding: CGFloat
20
+        let chipVerticalPadding: CGFloat
21
+        let outerPadding: CGFloat
22
+        let maxWidth: CGFloat
23
+        let barBackgroundOpacity: CGFloat
24
+        let materialOpacity: CGFloat
25
+        let shadowOpacity: CGFloat
26
+        let floatingInset: CGFloat
27
+
28
+        static let portrait = TabBarStyle(
29
+            showsTitles: true,
30
+            horizontalPadding: 16,
31
+            topPadding: 10,
32
+            bottomPadding: 8,
33
+            chipHorizontalPadding: 10,
34
+            chipVerticalPadding: 7,
35
+            outerPadding: 6,
36
+            maxWidth: 420,
37
+            barBackgroundOpacity: 0.10,
38
+            materialOpacity: 0.78,
39
+            shadowOpacity: 0,
40
+            floatingInset: 0
41
+        )
42
+
43
+        static let portraitCompact = TabBarStyle(
44
+            showsTitles: false,
45
+            horizontalPadding: 16,
46
+            topPadding: 10,
47
+            bottomPadding: 8,
48
+            chipHorizontalPadding: 12,
49
+            chipVerticalPadding: 10,
50
+            outerPadding: 6,
51
+            maxWidth: 320,
52
+            barBackgroundOpacity: 0.14,
53
+            materialOpacity: 0.90,
54
+            shadowOpacity: 0,
55
+            floatingInset: 0
56
+        )
57
+
58
+        static let landscapeInline = TabBarStyle(
59
+            showsTitles: true,
60
+            horizontalPadding: 12,
61
+            topPadding: 10,
62
+            bottomPadding: 8,
63
+            chipHorizontalPadding: 10,
64
+            chipVerticalPadding: 7,
65
+            outerPadding: 6,
66
+            maxWidth: 420,
67
+            barBackgroundOpacity: 0.10,
68
+            materialOpacity: 0.78,
69
+            shadowOpacity: 0,
70
+            floatingInset: 0
71
+        )
72
+
73
+        static let landscapeFloating = TabBarStyle(
74
+            showsTitles: false,
75
+            horizontalPadding: 16,
76
+            topPadding: 10,
77
+            bottomPadding: 0,
78
+            chipHorizontalPadding: 11,
79
+            chipVerticalPadding: 11,
80
+            outerPadding: 7,
81
+            maxWidth: 260,
82
+            barBackgroundOpacity: 0.16,
83
+            materialOpacity: 0.88,
84
+            shadowOpacity: 0.12,
85
+            floatingInset: 12
86
+        )
87
+    }
88
+
89
+    private enum MeterTab: String, Hashable {
90
+        case home
16 91
         case live
17 92
         case chart
93
+        case settings
18 94
 
19 95
         var title: String {
20 96
             switch self {
21
-            case .connection: return "Home"
97
+            case .home: return "Home"
22 98
             case .live: return "Live"
23 99
             case .chart: return "Chart"
100
+            case .settings: return "Settings"
24 101
             }
25 102
         }
26 103
 
27 104
         var systemImage: String {
28 105
             switch self {
29
-            case .connection: return "house.fill"
106
+            case .home: return "house.fill"
30 107
             case .live: return "waveform.path.ecg"
31 108
             case .chart: return "chart.xyaxis.line"
109
+            case .settings: return "gearshape.fill"
32 110
             }
33 111
         }
34 112
     }
@@ -37,27 +115,27 @@ struct MeterView: View {
37 115
     @Environment(\.dismiss) private var dismiss
38 116
 
39 117
     private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
118
+    #if os(iOS)
119
+    private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone
120
+    #else
121
+    private static let isPhone: Bool = false
122
+    #endif
40 123
     
41
-    @State var dataGroupsViewVisibility: Bool = false
42
-    @State var recordingViewVisibility: Bool = false
43
-    @State var measurementsViewVisibility: Bool = false
44
-    @State private var selectedMeterTab: MeterTab = .connection
124
+    @State private var selectedMeterTab: MeterTab = .home
45 125
     @State private var navBarTitle: String = "Meter"
46 126
     @State private var navBarShowRSSI: Bool = false
47 127
     @State private var navBarRSSI: Int = 0
48
-    private var myBounds: CGRect { UIScreen.main.bounds }
49
-    private let actionStripPadding: CGFloat = 10
50
-    private let actionDividerWidth: CGFloat = 1
51
-    private let actionButtonMaxWidth: CGFloat = 156
52
-    private let actionButtonMinWidth: CGFloat = 88
53
-    private let actionButtonHeight: CGFloat = 108
54
-    private let pageHorizontalPadding: CGFloat = 12
55
-    private let pageVerticalPadding: CGFloat = 12
56
-    private let contentCardPadding: CGFloat = 16
128
+    @State private var landscapeTabBarHeight: CGFloat = 0
57 129
 
58 130
     var body: some View {
59 131
         GeometryReader { proxy in
60 132
             let landscape = isLandscape(size: proxy.size)
133
+            let usesOverlayTabBar = landscape && Self.isPhone
134
+            let tabBarStyle = tabBarStyle(
135
+                for: landscape,
136
+                usesOverlayTabBar: usesOverlayTabBar,
137
+                size: proxy.size
138
+            )
61 139
 
62 140
             VStack(spacing: 0) {
63 141
                 if Self.isMacIPadApp {
@@ -65,9 +143,13 @@ struct MeterView: View {
65 143
                 }
66 144
                 Group {
67 145
                     if landscape {
68
-                        landscapeDeck(size: proxy.size)
146
+                        landscapeDeck(
147
+                            size: proxy.size,
148
+                            usesOverlayTabBar: usesOverlayTabBar,
149
+                            tabBarStyle: tabBarStyle
150
+                        )
69 151
                     } else {
70
-                        portraitContent(size: proxy.size)
152
+                        portraitContent(size: proxy.size, tabBarStyle: tabBarStyle)
71 153
                     }
72 154
                 }
73 155
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -100,6 +182,9 @@ struct MeterView: View {
100 182
                 navBarRSSI = newRSSI
101 183
             }
102 184
         }
185
+        .onChange(of: selectedMeterTab) { newTab in
186
+            meter.preferredTabIdentifier = newTab.rawValue
187
+        }
103 188
     }
104 189
 
105 190
     // MARK: - Custom navigation header for Designed-for-iPad on Mac
@@ -124,16 +209,18 @@ struct MeterView: View {
124 209
 
125 210
             Spacer()
126 211
 
212
+            MeterConnectionToolbarButton(
213
+                operationalState: meter.operationalState,
214
+                showsTitle: true,
215
+                connectAction: { meter.connect() },
216
+                disconnectAction: { meter.disconnect() }
217
+            )
218
+
127 219
             if meter.operationalState > .notPresent {
128 220
                 RSSIView(RSSI: meter.btSerial.averageRSSI)
129 221
                     .frame(width: 18, height: 18)
130 222
             }
131 223
 
132
-            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
133
-                Image(systemName: "gearshape.fill")
134
-                    .foregroundColor(.accentColor)
135
-            }
136
-            .buttonStyle(.plain)
137 224
         }
138 225
         .padding(.horizontal, 16)
139 226
         .padding(.vertical, 10)
@@ -149,59 +236,85 @@ struct MeterView: View {
149 236
         }
150 237
     }
151 238
 
152
-    private func portraitContent(size: CGSize) -> some View {
153
-        portraitSegmentedDeck(size: size)
239
+    private func portraitContent(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
240
+        portraitSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
154 241
     }
155 242
 
156
-    private func landscapeDeck(size: CGSize) -> some View {
157
-        landscapeSegmentedDeck(size: size)
243
+    @ViewBuilder
244
+    private func landscapeDeck(size: CGSize, usesOverlayTabBar: Bool, tabBarStyle: TabBarStyle) -> some View {
245
+        if usesOverlayTabBar {
246
+            landscapeOverlaySegmentedDeck(size: size, tabBarStyle: tabBarStyle)
247
+        } else {
248
+            landscapeSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
249
+        }
158 250
     }
159 251
 
160
-    private func landscapeSegmentedDeck(size: CGSize) -> some View {
252
+    private func landscapeOverlaySegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
253
+        ZStack(alignment: .top) {
254
+            landscapeSegmentedContent(size: size)
255
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
256
+                .padding(.top, landscapeContentTopPadding(for: tabBarStyle))
257
+                .id(displayedMeterTab)
258
+                .transition(.opacity.combined(with: .move(edge: .trailing)))
259
+
260
+            segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)
261
+        }
262
+        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
263
+        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
264
+        .onAppear {
265
+            restoreSelectedTab()
266
+        }
267
+        .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
268
+            if height > 0 {
269
+                landscapeTabBarHeight = height
270
+            }
271
+        }
272
+    }
273
+
274
+    private func landscapeSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
161 275
         VStack(spacing: 0) {
162
-            segmentedTabBar(horizontalPadding: 12)
276
+            segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)
163 277
 
164 278
             landscapeSegmentedContent(size: size)
165 279
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
166
-                .id(selectedMeterTab)
280
+                .id(displayedMeterTab)
167 281
                 .transition(.opacity.combined(with: .move(edge: .trailing)))
168 282
         }
169
-        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
283
+        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
170 284
         .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
171 285
         .onAppear {
172
-            normalizeSelectedTab()
173
-        }
174
-        .onChange(of: availableMeterTabs) { _ in
175
-            normalizeSelectedTab()
286
+            restoreSelectedTab()
176 287
         }
177 288
     }
178 289
 
179
-    private func portraitSegmentedDeck(size: CGSize) -> some View {
290
+    private func portraitSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
180 291
         VStack(spacing: 0) {
181
-            segmentedTabBar(horizontalPadding: 16)
292
+            segmentedTabBar(style: tabBarStyle)
182 293
 
183 294
             portraitSegmentedContent(size: size)
184 295
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
185
-                .id(selectedMeterTab)
296
+                .id(displayedMeterTab)
186 297
                 .transition(.opacity.combined(with: .move(edge: .trailing)))
187 298
         }
188
-        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
299
+        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
189 300
         .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
190 301
         .onAppear {
191
-            normalizeSelectedTab()
192
-        }
193
-        .onChange(of: availableMeterTabs) { _ in
194
-            normalizeSelectedTab()
302
+            restoreSelectedTab()
195 303
         }
196 304
     }
197 305
 
198
-    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
199
-        HStack {
306
+    private func segmentedTabBar(style: TabBarStyle, showsConnectionAction: Bool = false) -> some View {
307
+        let isFloating = style.floatingInset > 0
308
+        let cornerRadius = style.showsTitles ? 14.0 : 22.0
309
+        let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary
310
+        let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12)
311
+
312
+        return HStack {
200 313
             Spacer(minLength: 0)
201 314
 
202 315
             HStack(spacing: 8) {
203 316
                 ForEach(availableMeterTabs, id: \.self) { tab in
204
-                    let isSelected = selectedMeterTab == tab
317
+                    let isSelected = displayedMeterTab == tab
205 318
 
206 319
                     Button {
207 320
                         withAnimation(.easeInOut(duration: 0.2)) {
@@ -211,247 +324,169 @@ struct MeterView: View {
211 324
                         HStack(spacing: 6) {
212 325
                             Image(systemName: tab.systemImage)
213 326
                                 .font(.subheadline.weight(.semibold))
214
-                            Text(tab.title)
215
-                                .font(.subheadline.weight(.semibold))
216
-                                .lineLimit(1)
327
+                            if style.showsTitles {
328
+                                Text(tab.title)
329
+                                    .font(.subheadline.weight(.semibold))
330
+                                    .lineLimit(1)
331
+                            }
217 332
                         }
218
-                        .foregroundColor(isSelected ? .white : .primary)
219
-                        .padding(.horizontal, 10)
220
-                        .padding(.vertical, 7)
333
+                        .foregroundColor(
334
+                            isSelected
335
+                            ? .white
336
+                            : unselectedForegroundColor
337
+                        )
338
+                        .padding(.horizontal, style.chipHorizontalPadding)
339
+                        .padding(.vertical, style.chipVerticalPadding)
221 340
                         .frame(maxWidth: .infinity)
222 341
                         .background(
223 342
                             Capsule()
224
-                                .fill(isSelected ? meter.color : Color.secondary.opacity(0.12))
343
+                                .fill(
344
+                                    isSelected
345
+                                    ? meter.color.opacity(isFloating ? 0.94 : 1)
346
+                                    : unselectedChipFill
347
+                                )
225 348
                         )
226 349
                     }
227 350
                     .buttonStyle(.plain)
228 351
                     .accessibilityLabel(tab.title)
229 352
                 }
230 353
             }
231
-            .frame(maxWidth: 420)
232
-            .padding(6)
354
+            .frame(maxWidth: style.maxWidth)
355
+            .padding(style.outerPadding)
233 356
             .background(
234
-                RoundedRectangle(cornerRadius: 14, style: .continuous)
235
-                    .fill(Color.secondary.opacity(0.10))
357
+                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
358
+                    .fill(
359
+                        isFloating
360
+                        ? LinearGradient(
361
+                            colors: [
362
+                                Color.white.opacity(0.76),
363
+                                Color.white.opacity(0.52)
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
+                    )
236 377
             )
378
+            .overlay {
379
+                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
380
+                    .stroke(
381
+                        isFloating ? Color.black.opacity(0.08) : Color.clear,
382
+                        lineWidth: 1
383
+                    )
384
+            }
385
+            .background {
386
+                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
387
+                    .fill(.ultraThinMaterial)
388
+                    .opacity(style.materialOpacity)
389
+            }
390
+            .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12)
237 391
 
238 392
             Spacer(minLength: 0)
239 393
         }
240
-        .padding(.horizontal, horizontalPadding)
241
-        .padding(.top, 10)
242
-        .padding(.bottom, 8)
394
+        .padding(.horizontal, style.horizontalPadding)
395
+        .padding(.top, style.topPadding)
396
+        .padding(.bottom, style.bottomPadding)
243 397
         .background(
244
-            Rectangle()
245
-                .fill(.ultraThinMaterial)
246
-                .opacity(0.78)
247
-                .ignoresSafeArea(edges: .top)
398
+            GeometryReader { geometry in
399
+                Color.clear
400
+                    .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height)
401
+            }
248 402
         )
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
+        }
249 412
         .overlay(alignment: .bottom) {
250
-            Rectangle()
251
-                .fill(Color.secondary.opacity(0.12))
252
-                .frame(height: 1)
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
+            }
253 432
         }
254 433
     }
255 434
 
256 435
     @ViewBuilder
257 436
     private func landscapeSegmentedContent(size: CGSize) -> some View {
258
-        switch selectedMeterTab {
259
-        case .connection:
260
-            landscapeConnectionPage
437
+        switch displayedMeterTab {
438
+        case .home:
439
+            MeterHomeTabView(size: size, isLandscape: true)
261 440
         case .live:
262
-            if meter.operationalState == .dataIsAvailable {
263
-                landscapeLivePage(size: size)
264
-            } else {
265
-                landscapeConnectionPage
266
-            }
441
+            MeterLiveTabView(size: size, isLandscape: true)
267 442
         case .chart:
268
-            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
269
-                landscapeChartPage(size: size)
270
-            } else {
271
-                landscapeConnectionPage
443
+            MeterChartTabView(size: size, isLandscape: true)
444
+        case .settings:
445
+            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
446
+                withAnimation(.easeInOut(duration: 0.22)) {
447
+                    selectedMeterTab = .home
448
+                }
272 449
             }
273 450
         }
274 451
     }
275 452
 
276 453
     @ViewBuilder
277 454
     private func portraitSegmentedContent(size: CGSize) -> some View {
278
-        switch selectedMeterTab {
279
-        case .connection:
280
-            portraitConnectionPage(size: size)
455
+        switch displayedMeterTab {
456
+        case .home:
457
+            MeterHomeTabView(size: size, isLandscape: false)
281 458
         case .live:
282
-            if meter.operationalState == .dataIsAvailable {
283
-                portraitLivePage(size: size)
284
-            } else {
285
-                portraitConnectionPage(size: size)
286
-            }
459
+            MeterLiveTabView(size: size, isLandscape: false)
287 460
         case .chart:
288
-            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
289
-                portraitChartPage
290
-            } else {
291
-                portraitConnectionPage(size: size)
292
-            }
293
-        }
294
-    }
295
-
296
-    private func portraitConnectionPage(size: CGSize) -> some View {
297
-        portraitFace {
298
-            VStack(alignment: .leading, spacing: 12) {
299
-                connectionCard(
300
-                    compact: prefersCompactPortraitConnection(for: size),
301
-                    showsActions: meter.operationalState == .dataIsAvailable
302
-                )
303
-
304
-                homeInfoPreview
305
-            }
306
-        }
307
-    }
308
-
309
-    private func portraitLivePage(size: CGSize) -> some View {
310
-        portraitFace {
311
-            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
312
-                .padding(contentCardPadding)
313
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
314
-        }
315
-    }
316
-
317
-    private var portraitChartPage: some View {
318
-        portraitFace {
319
-            MeasurementChartView()
320
-                .environmentObject(meter.measurements)
321
-                .frame(minHeight: myBounds.height / 3.4)
322
-                .padding(contentCardPadding)
323
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
324
-        }
325
-    }
326
-
327
-    private var landscapeConnectionPage: some View {
328
-        landscapeFace {
329
-            VStack(alignment: .leading, spacing: 12) {
330
-                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
331
-
332
-                homeInfoPreview
333
-            }
334
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
335
-        }
336
-    }
337
-
338
-    private var homeInfoPreview: some View {
339
-        VStack(spacing: 14) {
340
-            MeterInfoCard(title: "Overview", tint: meter.color) {
341
-                MeterInfoRow(label: "Name", value: meter.name)
342
-                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
343
-                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
344
-                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
345
-                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
346
-            }
347
-
348
-            MeterInfoCard(title: "Identifiers", tint: .blue) {
349
-                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
350
-                if meter.modelNumber != 0 {
351
-                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
461
+            MeterChartTabView(size: size, isLandscape: false)
462
+        case .settings:
463
+            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
464
+                withAnimation(.easeInOut(duration: 0.22)) {
465
+                    selectedMeterTab = .home
352 466
                 }
353 467
             }
354
-
355
-            MeterInfoCard(title: "Screen Reporting", tint: .orange) {
356
-                if meter.reportsCurrentScreenIndex {
357
-                    MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
358
-                    Text("The active screen index is reported by the meter and mapped by the app to a known label.")
359
-                        .font(.footnote)
360
-                        .foregroundColor(.secondary)
361
-                } else {
362
-                    MeterInfoRow(label: "Current Screen", value: "Not Reported")
363
-                    Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
364
-                        .font(.footnote)
365
-                        .foregroundColor(.secondary)
366
-                }
367
-            }
368
-
369
-            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
370
-                if meter.operationalState == .dataIsAvailable {
371
-                    if !meter.firmwareVersion.isEmpty {
372
-                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
373
-                    }
374
-                    if meter.supportsChargerDetection {
375
-                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
376
-                    }
377
-                    if meter.serialNumber != 0 {
378
-                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
379
-                    }
380
-                    if meter.bootCount != 0 {
381
-                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
382
-                    }
383
-                } else {
384
-                    Text("Connect to the meter to load firmware, serial, and boot details.")
385
-                        .font(.footnote)
386
-                        .foregroundColor(.secondary)
387
-                }
388
-            }
389
-        }
390
-        .padding(.horizontal, pageHorizontalPadding)
391
-    }
392
-
393
-    private func landscapeLivePage(size: CGSize) -> some View {
394
-        landscapeFace {
395
-            LiveView(compactLayout: true, availableSize: size)
396
-                .padding(contentCardPadding)
397
-                .frame(maxWidth: .infinity, alignment: .topLeading)
398
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
399
-        }
400
-    }
401
-
402
-    private func landscapeChartPage(size: CGSize) -> some View {
403
-        landscapeFace {
404
-            MeasurementChartView()
405
-                .environmentObject(meter.measurements)
406
-                .frame(height: max(250, size.height - 44))
407
-                .padding(contentCardPadding)
408
-                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
409
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
410 468
         }
411 469
     }
412 470
 
413 471
     private var availableMeterTabs: [MeterTab] {
414
-        var tabs: [MeterTab] = [.connection]
415
-
416
-        if meter.operationalState == .dataIsAvailable {
417
-            tabs.append(.live)
418
-
419
-            if meter.measurements.power.context.isValid {
420
-                tabs.append(.chart)
421
-            }
422
-        }
423
-
424
-        return tabs
472
+        [.home, .live, .chart, .settings]
425 473
     }
426 474
 
427
-    private func normalizeSelectedTab() {
428
-        guard availableMeterTabs.contains(selectedMeterTab) else {
429
-            withAnimation(.easeInOut(duration: 0.22)) {
430
-                selectedMeterTab = .connection
431
-            }
432
-            return
475
+    private var displayedMeterTab: MeterTab {
476
+        if availableMeterTabs.contains(selectedMeterTab) {
477
+            return selectedMeterTab
433 478
         }
479
+        return .home
434 480
     }
435 481
 
436
-    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
437
-        size.height < 760 || size.width < 380
438
-    }
439
-
440
-    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
441
-        ScrollView {
442
-            content()
443
-                .frame(maxWidth: .infinity, alignment: .topLeading)
444
-                .padding(.horizontal, pageHorizontalPadding)
445
-                .padding(.vertical, pageVerticalPadding)
482
+    private func restoreSelectedTab() {
483
+        guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else {
484
+            meter.preferredTabIdentifier = MeterTab.home.rawValue
485
+            selectedMeterTab = .home
486
+            return
446 487
         }
447
-    }
448 488
 
449
-    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
450
-        content()
451
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
452
-        .padding(.horizontal, pageHorizontalPadding)
453
-        .padding(.vertical, pageVerticalPadding)
454
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
489
+        selectedMeterTab = restoredTab
455 490
     }
456 491
 
457 492
     private var meterBackground: some View {
@@ -471,259 +506,37 @@ struct MeterView: View {
471 506
         size.width > size.height
472 507
     }
473 508
 
474
-    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
475
-        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
476
-            HStack(alignment: .top) {
477
-                meterIdentity(compact: compact)
478
-                Spacer()
479
-                statusBadge
480
-            }
481
-
482
-            if compact {
483
-                Spacer(minLength: 0)
484
-            }
485
-
486
-            connectionActionArea(compact: compact)
487
-
488
-            if showsActions {
489
-                VStack(spacing: compact ? 10 : 12) {
490
-                    Rectangle()
491
-                        .fill(Color.secondary.opacity(0.12))
492
-                        .frame(height: 1)
493
-
494
-                    actionGrid(compact: compact, embedded: true)
495
-                }
496
-            }
497
-        }
498
-        .padding(compact ? 16 : 20)
499
-        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
500
-        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
501
-    }
502
-
503
-    private func meterIdentity(compact: Bool) -> some View {
504
-        HStack(alignment: .firstTextBaseline, spacing: 8) {
505
-            Text(meter.name)
506
-                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
507
-                .lineLimit(1)
508
-                .minimumScaleFactor(0.8)
509
-
510
-            Text(meter.deviceModelName)
511
-                .font((compact ? Font.caption : .subheadline).weight(.semibold))
512
-                .foregroundColor(.secondary)
513
-                .lineLimit(1)
514
-                .minimumScaleFactor(0.8)
515
-        }
516
-    }
517
-
518
-    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
519
-        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
520
-
521
-        return GeometryReader { proxy in
522
-            let buttonWidth = actionButtonWidth(for: proxy.size.width)
523
-            let stripWidth = actionStripWidth(for: buttonWidth)
524
-            let stripContent = HStack(spacing: 0) {
525
-                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
526
-                    dataGroupsViewVisibility.toggle()
527
-                }
528
-                .sheet(isPresented: $dataGroupsViewVisibility) {
529
-                    DataGroupsView(visibility: $dataGroupsViewVisibility)
530
-                        .environmentObject(meter)
531
-                }
532
-
533
-                if meter.supportsRecordingView {
534
-                    actionStripDivider(height: currentActionHeight)
535
-                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
536
-                        recordingViewVisibility.toggle()
537
-                    }
538
-                    .sheet(isPresented: $recordingViewVisibility) {
539
-                        RecordingView(visibility: $recordingViewVisibility)
540
-                            .environmentObject(meter)
541
-                    }
542
-                }
543
-
544
-                actionStripDivider(height: currentActionHeight)
545
-                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
546
-                    measurementsViewVisibility.toggle()
547
-                }
548
-                .sheet(isPresented: $measurementsViewVisibility) {
549
-                    MeasurementsView(visibility: $measurementsViewVisibility)
550
-                        .environmentObject(meter.measurements)
551
-                }
552
-            }
553
-            .padding(actionStripPadding)
554
-            .frame(width: stripWidth)
555
-
556
-            HStack {
557
-                Spacer(minLength: 0)
558
-                stripContent
559
-                    .meterCard(
560
-                        tint: embedded ? meter.color : Color.secondary,
561
-                        fillOpacity: embedded ? 0.08 : 0.10,
562
-                        strokeOpacity: embedded ? 0.14 : 0.16,
563
-                        cornerRadius: embedded ? 24 : 22
564
-                    )
565
-                Spacer(minLength: 0)
566
-            }
509
+    private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle {
510
+        if usesOverlayTabBar {
511
+            return .landscapeFloating
567 512
         }
568
-        .frame(height: currentActionHeight + (actionStripPadding * 2))
569
-    }
570 513
 
571
-    private func connectionActionArea(compact: Bool = false) -> some View {
572
-        let connected = meter.operationalState >= .peripheralConnectionPending
573
-        let tint = connected ? disconnectActionTint : connectActionTint
574
-
575
-        return Group {
576
-            if meter.operationalState == .notPresent {
577
-                HStack(spacing: 10) {
578
-                    Image(systemName: "exclamationmark.triangle.fill")
579
-                        .foregroundColor(.orange)
580
-                    Text("Not found at this time.")
581
-                        .fontWeight(.semibold)
582
-                    Spacer()
583
-                }
584
-                .padding(compact ? 12 : 16)
585
-                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
586
-            } else {
587
-                Button(action: {
588
-                    if meter.operationalState < .peripheralConnectionPending {
589
-                        meter.connect()
590
-                    } else {
591
-                        meter.disconnect()
592
-                    }
593
-                }) {
594
-                    HStack(spacing: 12) {
595
-                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
596
-                            .foregroundColor(tint)
597
-                            .frame(width: 30, height: 30)
598
-                            .background(Circle().fill(tint.opacity(0.12)))
599
-                        Text(connected ? "Disconnect" : "Connect")
600
-                            .fontWeight(.semibold)
601
-                            .foregroundColor(.primary)
602
-                        Spacer()
603
-                    }
604
-                    .padding(.horizontal, 18)
605
-                    .padding(.vertical, compact ? 10 : 14)
606
-                    .frame(maxWidth: .infinity)
607
-                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
608
-                }
609
-                .buttonStyle(.plain)
610
-            }
514
+        if landscape {
515
+            return .landscapeInline
611 516
         }
612
-    }
613 517
 
614
-    fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
615
-        Button(action: action) {
616
-            VStack(spacing: compact ? 8 : 10) {
617
-                Image(systemName: icon)
618
-                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
619
-                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
620
-                    .background(Circle().fill(tint.opacity(0.14)))
621
-                Text(title)
622
-                    .font((compact ? Font.caption : .footnote).weight(.semibold))
623
-                    .multilineTextAlignment(.center)
624
-                    .lineLimit(2)
625
-                    .minimumScaleFactor(0.9)
626
-            }
627
-            .foregroundColor(tint)
628
-            .frame(width: width, height: height)
629
-            .contentShape(Rectangle())
518
+        if Self.isPhone && size.width < 390 {
519
+            return .portraitCompact
630 520
         }
631
-        .buttonStyle(.plain)
632
-    }
633
-
634
-    private var visibleActionButtonCount: CGFloat {
635
-        meter.supportsRecordingView ? 3 : 2
636
-    }
637
-
638
-    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
639
-        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
640
-        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
641
-        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
642
-        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
643
-    }
644
-
645
-    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
646
-        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
647
-        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
648
-    }
649 521
 
650
-    private func actionStripDivider(height: CGFloat) -> some View {
651
-        Rectangle()
652
-            .fill(Color.secondary.opacity(0.16))
653
-            .frame(width: actionDividerWidth, height: max(44, height - 22))
522
+        return .portrait
654 523
     }
655 524
 
656
-    private var statusBadge: some View {
657
-        Text(statusText)
658
-            .font(.caption.weight(.bold))
659
-            .padding(.horizontal, 12)
660
-            .padding(.vertical, 6)
661
-            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
662
-    }
663
-
664
-    private var connectActionTint: Color {
665
-        Color(red: 0.20, green: 0.46, blue: 0.43)
666
-    }
667
-
668
-    private var disconnectActionTint: Color {
669
-        Color(red: 0.66, green: 0.39, blue: 0.35)
670
-    }
671
-
672
-    private var statusText: String {
673
-        switch meter.operationalState {
674
-        case .notPresent:
675
-            return "Missing"
676
-        case .peripheralNotConnected:
677
-            return "Ready"
678
-        case .peripheralConnectionPending:
679
-            return "Connecting"
680
-        case .peripheralConnected:
681
-            return "Linked"
682
-        case .peripheralReady:
683
-            return "Preparing"
684
-        case .comunicating:
685
-            return "Syncing"
686
-        case .dataIsAvailable:
687
-            return "Live"
525
+    private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
526
+        if style.floatingInset > 0 {
527
+            return max(landscapeTabBarHeight * 0.44, 26)
688 528
         }
689
-    }
690 529
 
691
-    private var statusColor: Color {
692
-        Meter.operationalColor(for: meter.operationalState)
530
+        return max(landscapeTabBarHeight - 6, 0)
693 531
     }
694
-}
695
-
696
-
697
-private struct MeterInfoCard<Content: View>: View {
698
-    let title: String
699
-    let tint: Color
700
-    @ViewBuilder var content: Content
701 532
 
702
-    var body: some View {
703
-        VStack(alignment: .leading, spacing: 12) {
704
-            Text(title)
705
-                .font(.headline)
706
-            content
707
-        }
708
-        .frame(maxWidth: .infinity, alignment: .leading)
709
-        .padding(18)
710
-        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
711
-    }
712 533
 }
713 534
 
714
-private struct MeterInfoRow: View {
715
-    let label: String
716
-    let value: String
535
+private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
536
+    static var defaultValue: CGFloat = 0
717 537
 
718
-    var body: some View {
719
-        HStack {
720
-            Text(label)
721
-            Spacer()
722
-            Text(value)
723
-                .foregroundColor(.secondary)
724
-                .multilineTextAlignment(.trailing)
725
-        }
726
-        .font(.footnote)
538
+    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
539
+        value = max(value, nextValue())
727 540
     }
728 541
 }
729 542
 
@@ -743,13 +556,17 @@ private struct IOSOnlyNavBar: ViewModifier {
743 556
                 .navigationBarTitle(title)
744 557
                 .toolbar {
745 558
                     ToolbarItemGroup(placement: .navigationBarTrailing) {
559
+                        MeterConnectionToolbarButton(
560
+                            operationalState: meter.operationalState,
561
+                            showsTitle: false,
562
+                            connectAction: { meter.connect() },
563
+                            disconnectAction: { meter.disconnect() }
564
+                        )
565
+                        .font(.body.weight(.semibold))
746 566
                         if showRSSI {
747 567
                             RSSIView(RSSI: rssi)
748 568
                                 .frame(width: 18, height: 18)
749 569
                         }
750
-                        NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
751
-                            Image(systemName: "gearshape.fill")
752
-                        }
753 570
                     }
754 571
                 }
755 572
         } else {
+22 -42
USB Meter/Views/Meter/RecordingView.swift → USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  RecordingView.swift
2
+//  ChargeRecordSheetView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 09/03/2020.
@@ -8,7 +8,7 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
-struct RecordingView: View {
11
+struct ChargeRecordSheetView: View {
12 12
     
13 13
     @Binding var visibility: Bool
14 14
     @EnvironmentObject private var usbMeter: Meter
@@ -42,23 +42,15 @@ struct RecordingView: View {
42 42
                     .padding(18)
43 43
                     .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
44 44
 
45
-                    HStack(alignment: .top) {
46
-                        VStack(alignment: .leading, spacing: 10) {
47
-                            Text("Capacity")
48
-                            Text("Energy")
49
-                            Text("Duration")
50
-                            Text("Stop Threshold")
51
-                        }
52
-                        Spacer()
53
-                        VStack(alignment: .trailing, spacing: 10) {
54
-                            Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
55
-                            Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
56
-                            Text(usbMeter.chargeRecordDurationDescription)
57
-                            Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
58
-                        }
59
-                        .monospacedDigit()
60
-                    }
61
-                    .font(.footnote.weight(.semibold))
45
+                    ChargeRecordMetricsTableView(
46
+                        labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
47
+                        values: [
48
+                            "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
49
+                            "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
50
+                            usbMeter.chargeRecordDurationDescription,
51
+                            "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
52
+                        ]
53
+                    )
62 54
                     .padding(18)
63 55
                     .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
64 56
 
@@ -106,27 +98,15 @@ struct RecordingView: View {
106 98
                         VStack(alignment: .leading, spacing: 12) {
107 99
                             Text("Meter Totals")
108 100
                                 .font(.headline)
109
-                            HStack(alignment: .top) {
110
-                                VStack(alignment: .leading, spacing: 10) {
111
-                                    Text("Capacity")
112
-                                    Text("Energy")
113
-                                    Text("Duration")
114
-                                    Text("Meter Threshold")
115
-                                }
116
-                                Spacer()
117
-                                VStack(alignment: .trailing, spacing: 10) {
118
-                                    Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
119
-                                    Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
120
-                                    Text(usbMeter.recordingDurationDescription)
121
-                                    if usbMeter.supportsRecordingThreshold {
122
-                                        Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
123
-                                    } else {
124
-                                        Text("Read-only")
125
-                                    }
126
-                                }
127
-                                .monospacedDigit()
128
-                            }
129
-                            .font(.footnote.weight(.semibold))
101
+                            ChargeRecordMetricsTableView(
102
+                                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
103
+                                values: [
104
+                                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
105
+                                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
106
+                                    usbMeter.recordingDurationDescription,
107
+                                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
108
+                                ]
109
+                            )
130 110
                             Text("These values are reported by the meter for the active data group.")
131 111
                                 .font(.footnote)
132 112
                                 .foregroundColor(.secondary)
@@ -161,8 +141,8 @@ struct RecordingView: View {
161 141
     }
162 142
 }
163 143
 
164
-struct RecordingView_Previews: PreviewProvider {
144
+struct ChargeRecordSheetView_Previews: PreviewProvider {
165 145
     static var previews: some View {
166
-        RecordingView(visibility: .constant(true))
146
+        ChargeRecordSheetView(visibility: .constant(true))
167 147
     }
168 148
 }
+33 -0
USB Meter/Views/Meter/Sheets/ChargeRecord/Subviews/ChargeRecordMetricsTableView.swift
@@ -0,0 +1,33 @@
1
+//
2
+//  ChargeRecordMetricsTableView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ChargeRecordMetricsTableView: View {
13
+    let labels: [String]
14
+    let values: [String]
15
+
16
+    var body: some View {
17
+        HStack(alignment: .top) {
18
+            VStack(alignment: .leading, spacing: 10) {
19
+                ForEach(labels, id: \.self) { label in
20
+                    Text(label)
21
+                }
22
+            }
23
+            Spacer()
24
+            VStack(alignment: .trailing, spacing: 10) {
25
+                ForEach(Array(values.enumerated()), id: \.offset) { _, value in
26
+                    Text(value)
27
+                }
28
+            }
29
+            .monospacedDigit()
30
+        }
31
+        .font(.footnote.weight(.semibold))
32
+    }
33
+}
+2 -2
USB Meter/Views/Meter/Data Groups/DataGroupsView.swift → USB Meter/Views/Meter/Sheets/DataGroups/DataGroupsSheetView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  DataGroupsView.swift
2
+//  DataGroupsSheetView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 10/03/2020.
@@ -8,7 +8,7 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
-struct DataGroupsView: View {
11
+struct DataGroupsSheetView: View {
12 12
     
13 13
     @Binding var visibility: Bool
14 14
     @EnvironmentObject private var usbMeter: Meter
+1 -1
USB Meter/Views/Meter/Data Groups/DataGroupRowView.swift → USB Meter/Views/Meter/Sheets/DataGroups/Subviews/DataGroupRowView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  DataGroupView.swift
2
+//  DataGroupRowView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 10/03/2020.
+19 -12
USB Meter/Views/Meter/Measurements/MeasurementsView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/MeasurementSeriesSheetView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  MeasurementView.swift
2
+//  MeasurementSeriesSheetView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 13/04/2020.
@@ -8,39 +8,42 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
-struct MeasurementsView: View {
11
+struct MeasurementSeriesSheetView: View {
12 12
     
13 13
     @EnvironmentObject private var measurements: Measurements
14 14
     
15 15
     @Binding var visibility: Bool
16 16
     
17 17
     var body: some View {
18
+        let seriesPoints = measurements.power.samplePoints
19
+
18 20
         NavigationView {
19 21
             ScrollView {
20 22
                 VStack(alignment: .leading, spacing: 14) {
21 23
                     VStack(alignment: .leading, spacing: 8) {
22
-                        Text("App History")
24
+                        Text("Measurement Series")
23 25
                             .font(.system(.title3, design: .rounded).weight(.bold))
24
-                        Text("Local timeline captured by the app while connected to the meter.")
26
+                        Text("Buffered measurement series captured from the meter for analysis, charts, and correlations.")
25 27
                             .font(.footnote)
26 28
                             .foregroundColor(.secondary)
27 29
                     }
28 30
                     .padding(18)
29 31
                     .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24)
30 32
 
31
-                    if measurements.power.points.isEmpty {
32
-                        Text("No history samples have been captured yet.")
33
+                    if seriesPoints.isEmpty {
34
+                        Text("No measurement samples have been captured yet.")
33 35
                             .font(.footnote)
34 36
                             .foregroundColor(.secondary)
35 37
                             .padding(18)
36 38
                             .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
37 39
                     } else {
38 40
                         LazyVStack(spacing: 12) {
39
-                            ForEach(measurements.power.points) { point in
40
-                                MeasurementPointView(
41
+                            ForEach(seriesPoints) { point in
42
+                                MeasurementSeriesSampleView(
41 43
                                     power: point,
42 44
                                     voltage: measurements.voltage.points[point.id],
43
-                                    current: measurements.current.points[point.id]
45
+                                    current: measurements.current.points[point.id],
46
+                                    energy: energyPoint(for: point.timestamp)
44 47
                                 )
45 48
                             }
46 49
                         }
@@ -58,13 +61,17 @@ struct MeasurementsView: View {
58 61
             )
59 62
             .navigationBarItems(
60 63
                 leading: Button("Done") { visibility.toggle() },
61
-                trailing: Button("Clear") {
62
-                    measurements.reset()
64
+                trailing: Button("Reset Series") {
65
+                    measurements.resetSeries()
63 66
                 }
64 67
                 .foregroundColor(.red)
65 68
             )
66
-            .navigationBarTitle("App History", displayMode: .inline)
69
+            .navigationBarTitle("Measurement Series", displayMode: .inline)
67 70
         }
68 71
         .navigationViewStyle(StackNavigationViewStyle())
69 72
     }
73
+
74
+    private func energyPoint(for timestamp: Date) -> Measurements.Measurement.Point? {
75
+        measurements.energy.samplePoints.last { $0.timestamp == timestamp }
76
+    }
70 77
 }
+6 -2
USB Meter/Views/Meter/Measurements/MeasurementPointView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/Subviews/MeasurementSeriesSampleView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  MeasurementView.swift
2
+//  MeasurementSeriesSampleView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 13/04/2020.
@@ -8,11 +8,12 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
-struct MeasurementPointView: View {
11
+struct MeasurementSeriesSampleView: View {
12 12
     
13 13
     var power: Measurements.Measurement.Point
14 14
     var voltage: Measurements.Measurement.Point
15 15
     var current: Measurements.Measurement.Point
16
+    var energy: Measurements.Measurement.Point?
16 17
 
17 18
     @State var showDetail: Bool = false
18 19
     
@@ -48,6 +49,9 @@ struct MeasurementPointView: View {
48 49
                     detailRow(title: "Power", value: "\(power.value.format(fractionDigits: 4)) W")
49 50
                     detailRow(title: "Voltage", value: "\(voltage.value.format(fractionDigits: 4)) V")
50 51
                     detailRow(title: "Current", value: "\(current.value.format(fractionDigits: 4)) A")
52
+                    if let energy {
53
+                        detailRow(title: "Energy", value: "\(energy.value.format(fractionDigits: 4)) Wh")
54
+                    }
51 55
                 }
52 56
             }
53 57
         }
+72 -0
USB Meter/Views/Meter/Tabs/Chart/MeterChartTabView.swift
@@ -0,0 +1,72 @@
1
+//
2
+//  MeterChartTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterChartTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let size: CGSize
12
+    let isLandscape: Bool
13
+
14
+    private let pageHorizontalPadding: CGFloat = 12
15
+    private let pageVerticalPadding: CGFloat = 12
16
+    private let portraitContentCardHorizontalPadding: CGFloat = 8
17
+    private let portraitContentCardVerticalPadding: CGFloat = 12
18
+
19
+    private var prefersCompactPortraitLayout: Bool {
20
+        size.height < 760 || size.width < 380
21
+    }
22
+
23
+    private var prefersCompactLandscapeLayout: Bool {
24
+        size.height < 430
25
+    }
26
+
27
+    var body: some View {
28
+        Group {
29
+            if isLandscape {
30
+                landscapeFace {
31
+                    MeasurementChartView(
32
+                        compactLayout: prefersCompactLandscapeLayout,
33
+                        availableSize: size
34
+                    )
35
+                        .environmentObject(meter.measurements)
36
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
37
+                        .padding(10)
38
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
39
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
40
+                }
41
+            } else {
42
+                portraitFace {
43
+                    MeasurementChartView(
44
+                        compactLayout: prefersCompactPortraitLayout,
45
+                        availableSize: size
46
+                    )
47
+                        .environmentObject(meter.measurements)
48
+                        .padding(.horizontal, portraitContentCardHorizontalPadding)
49
+                        .padding(.vertical, portraitContentCardVerticalPadding)
50
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
51
+                }
52
+            }
53
+        }
54
+    }
55
+
56
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
57
+        ScrollView {
58
+            content()
59
+                .frame(maxWidth: .infinity, alignment: .topLeading)
60
+                .padding(.horizontal, pageHorizontalPadding)
61
+                .padding(.vertical, pageVerticalPadding)
62
+        }
63
+    }
64
+
65
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
66
+        content()
67
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
68
+            .padding(.horizontal, pageHorizontalPadding)
69
+            .padding(.vertical, pageVerticalPadding)
70
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
71
+    }
72
+}
+243 -0
USB Meter/Views/Meter/Tabs/Home/MeterHomeTabView.swift
@@ -0,0 +1,243 @@
1
+//
2
+//  MeterHomeTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterHomeTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let size: CGSize
12
+    let isLandscape: Bool
13
+
14
+    @State private var dataGroupsViewVisibility = false
15
+    @State private var recordingViewVisibility = false
16
+    @State private var measurementsViewVisibility = false
17
+
18
+    private let actionStripPadding: CGFloat = 10
19
+    private let actionDividerWidth: CGFloat = 1
20
+    private let actionButtonMaxWidth: CGFloat = 156
21
+    private let actionButtonMinWidth: CGFloat = 88
22
+    private let actionButtonHeight: CGFloat = 108
23
+    private let pageHorizontalPadding: CGFloat = 12
24
+    private let pageVerticalPadding: CGFloat = 12
25
+
26
+    var body: some View {
27
+        Group {
28
+            if isLandscape {
29
+                landscapeFace {
30
+                    VStack(alignment: .leading, spacing: 12) {
31
+                        connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
32
+                        MeterOverviewSectionView(meter: meter)
33
+                    }
34
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
35
+                }
36
+            } else {
37
+                portraitFace {
38
+                    VStack(alignment: .leading, spacing: 12) {
39
+                        connectionCard(
40
+                            compact: prefersCompactPortraitLayout,
41
+                            showsActions: meter.operationalState == .dataIsAvailable
42
+                        )
43
+                        MeterOverviewSectionView(meter: meter)
44
+                    }
45
+                }
46
+            }
47
+        }
48
+    }
49
+
50
+    private var prefersCompactPortraitLayout: Bool {
51
+        size.height < 760 || size.width < 380
52
+    }
53
+
54
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
55
+        ScrollView {
56
+            content()
57
+                .frame(maxWidth: .infinity, alignment: .topLeading)
58
+                .padding(.horizontal, pageHorizontalPadding)
59
+                .padding(.vertical, pageVerticalPadding)
60
+        }
61
+    }
62
+
63
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
64
+        ScrollView {
65
+            content()
66
+                .frame(maxWidth: .infinity, alignment: .topLeading)
67
+                .padding(.horizontal, pageHorizontalPadding)
68
+                .padding(.vertical, pageVerticalPadding)
69
+        }
70
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
71
+    }
72
+
73
+    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
74
+        let cardContent = VStack(alignment: .leading, spacing: compact ? 12 : 18) {
75
+            HStack(alignment: .top) {
76
+                meterIdentity(compact: compact)
77
+                Spacer()
78
+                statusBadge
79
+            }
80
+
81
+            connectionActionArea(compact: compact)
82
+
83
+            if showsActions {
84
+                VStack(spacing: compact ? 10 : 12) {
85
+                    Rectangle()
86
+                        .fill(Color.secondary.opacity(0.12))
87
+                        .frame(height: 1)
88
+
89
+                    actionGrid(compact: compact, embedded: true)
90
+                }
91
+            }
92
+        }
93
+        .padding(compact ? 16 : 20)
94
+        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
95
+
96
+        return cardContent
97
+            .frame(maxWidth: .infinity, alignment: .topLeading)
98
+    }
99
+
100
+    private func meterIdentity(compact: Bool) -> some View {
101
+        HStack(alignment: .firstTextBaseline, spacing: 8) {
102
+            Text(meter.name)
103
+                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
104
+                .lineLimit(1)
105
+                .minimumScaleFactor(0.8)
106
+
107
+            Text(meter.deviceModelName)
108
+                .font((compact ? Font.caption : .subheadline).weight(.semibold))
109
+                .foregroundColor(.secondary)
110
+                .lineLimit(1)
111
+                .minimumScaleFactor(0.8)
112
+        }
113
+    }
114
+
115
+    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
116
+        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
117
+
118
+        return GeometryReader { proxy in
119
+            let buttonWidth = actionButtonWidth(for: proxy.size.width)
120
+            let stripWidth = actionStripWidth(for: buttonWidth)
121
+            let stripContent = HStack(spacing: 0) {
122
+                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
123
+                    dataGroupsViewVisibility.toggle()
124
+                }
125
+                .sheet(isPresented: $dataGroupsViewVisibility) {
126
+                    DataGroupsSheetView(visibility: $dataGroupsViewVisibility)
127
+                        .environmentObject(meter)
128
+                }
129
+
130
+                if meter.supportsRecordingView {
131
+                    actionStripDivider(height: currentActionHeight)
132
+                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
133
+                        recordingViewVisibility.toggle()
134
+                    }
135
+                    .sheet(isPresented: $recordingViewVisibility) {
136
+                        ChargeRecordSheetView(visibility: $recordingViewVisibility)
137
+                            .environmentObject(meter)
138
+                    }
139
+                }
140
+
141
+                actionStripDivider(height: currentActionHeight)
142
+                meterSheetButton(icon: "waveform.path.ecg", title: "Measurement Series", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
143
+                    measurementsViewVisibility.toggle()
144
+                }
145
+                .sheet(isPresented: $measurementsViewVisibility) {
146
+                    MeasurementSeriesSheetView(visibility: $measurementsViewVisibility)
147
+                        .environmentObject(meter.measurements)
148
+                }
149
+            }
150
+            .padding(actionStripPadding)
151
+            .frame(width: stripWidth)
152
+
153
+            HStack {
154
+                Spacer(minLength: 0)
155
+                stripContent
156
+                    .meterCard(
157
+                        tint: embedded ? meter.color : Color.secondary,
158
+                        fillOpacity: embedded ? 0.08 : 0.10,
159
+                        strokeOpacity: embedded ? 0.14 : 0.16,
160
+                        cornerRadius: embedded ? 24 : 22
161
+                    )
162
+                Spacer(minLength: 0)
163
+            }
164
+        }
165
+        .frame(height: currentActionHeight + (actionStripPadding * 2))
166
+    }
167
+
168
+    private func connectionActionArea(compact: Bool = false) -> some View {
169
+        MeterConnectionActionView(
170
+            operationalState: meter.operationalState,
171
+            compact: compact
172
+        )
173
+    }
174
+
175
+    private func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
176
+        Button(action: action) {
177
+            VStack(spacing: compact ? 8 : 10) {
178
+                Image(systemName: icon)
179
+                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
180
+                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
181
+                    .background(Circle().fill(tint.opacity(0.14)))
182
+                Text(title)
183
+                    .font((compact ? Font.caption : .footnote).weight(.semibold))
184
+                    .multilineTextAlignment(.center)
185
+                    .lineLimit(2)
186
+                    .minimumScaleFactor(0.9)
187
+            }
188
+            .foregroundColor(tint)
189
+            .frame(width: width, height: height)
190
+            .contentShape(Rectangle())
191
+        }
192
+        .buttonStyle(.plain)
193
+    }
194
+
195
+    private var visibleActionButtonCount: CGFloat {
196
+        meter.supportsRecordingView ? 3 : 2
197
+    }
198
+
199
+    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
200
+        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
201
+        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
202
+        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
203
+        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
204
+    }
205
+
206
+    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
207
+        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
208
+        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
209
+    }
210
+
211
+    private func actionStripDivider(height: CGFloat) -> some View {
212
+        Rectangle()
213
+            .fill(Color.secondary.opacity(0.16))
214
+            .frame(width: actionDividerWidth, height: max(44, height - 22))
215
+    }
216
+
217
+    private var statusBadge: some View {
218
+        MeterConnectionStatusBadgeView(text: statusText, color: statusColor)
219
+    }
220
+
221
+    private var statusText: String {
222
+        switch meter.operationalState {
223
+        case .notPresent:
224
+            return "Missing"
225
+        case .peripheralNotConnected:
226
+            return "Ready"
227
+        case .peripheralConnectionPending:
228
+            return "Connecting"
229
+        case .peripheralConnected:
230
+            return "Linked"
231
+        case .peripheralReady:
232
+            return "Preparing"
233
+        case .comunicating:
234
+            return "Syncing"
235
+        case .dataIsAvailable:
236
+            return "Live"
237
+        }
238
+    }
239
+
240
+    private var statusColor: Color {
241
+        Meter.operationalColor(for: meter.operationalState)
242
+    }
243
+}
+80 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionActionView.swift
@@ -0,0 +1,80 @@
1
+//
2
+//  MeterConnectionActionView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct MeterConnectionActionView: View {
13
+    let operationalState: Meter.OperationalState
14
+    let compact: Bool
15
+
16
+    var body: some View {
17
+        if operationalState == .notPresent {
18
+            HStack(spacing: 10) {
19
+                Image(systemName: "exclamationmark.triangle.fill")
20
+                    .foregroundColor(.orange)
21
+                Text("Not found at this time.")
22
+                    .fontWeight(.semibold)
23
+                Spacer()
24
+            }
25
+            .padding(compact ? 12 : 16)
26
+            .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
27
+        }
28
+    }
29
+}
30
+
31
+struct MeterConnectionToolbarButton: View {
32
+    let operationalState: Meter.OperationalState
33
+    let showsTitle: Bool
34
+    let connectAction: () -> Void
35
+    let disconnectAction: () -> Void
36
+
37
+    private var connected: Bool {
38
+        operationalState >= .peripheralConnectionPending
39
+    }
40
+
41
+    private var actionTint: Color {
42
+        connected ? Color(red: 0.66, green: 0.39, blue: 0.35) : Color(red: 0.20, green: 0.46, blue: 0.43)
43
+    }
44
+
45
+    private var title: String {
46
+        connected ? "Disconnect" : "Connect"
47
+    }
48
+
49
+    private var systemImage: String {
50
+        if connected {
51
+            #if targetEnvironment(macCatalyst)
52
+            return "bolt.slash.circle.fill"
53
+            #else
54
+            return "link.badge.minus"
55
+            #endif
56
+        }
57
+        return "bolt.horizontal.circle.fill"
58
+    }
59
+
60
+    var body: some View {
61
+        if operationalState != .notPresent {
62
+            Button(action: {
63
+                if connected {
64
+                    disconnectAction()
65
+                } else {
66
+                    connectAction()
67
+                }
68
+            }) {
69
+                if showsTitle {
70
+                    Label(title, systemImage: systemImage)
71
+                } else {
72
+                    Image(systemName: systemImage)
73
+                }
74
+            }
75
+            .foregroundStyle(actionTint)
76
+            .accessibilityLabel(title)
77
+            .help(title)
78
+        }
79
+    }
80
+}
+23 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionStatusBadgeView.swift
@@ -0,0 +1,23 @@
1
+//
2
+//  MeterConnectionStatusBadgeView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct MeterConnectionStatusBadgeView: View {
13
+    let text: String
14
+    let color: Color
15
+
16
+    var body: some View {
17
+        Text(text)
18
+            .font(.caption.weight(.bold))
19
+            .padding(.horizontal, 12)
20
+            .padding(.vertical, 6)
21
+            .meterCard(tint: color, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
22
+    }
23
+}
+57 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterOverviewSectionView.swift
@@ -0,0 +1,57 @@
1
+//
2
+//  MeterOverviewSectionView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct MeterOverviewSectionView: View {
13
+    let meter: Meter
14
+
15
+    var body: some View {
16
+        VStack(spacing: 14) {
17
+            MeterInfoCardView(title: "Overview", tint: meter.color) {
18
+                MeterInfoRowView(label: "Name", value: meter.name)
19
+                MeterInfoRowView(label: "Device Model", value: meter.deviceModelName)
20
+                MeterInfoRowView(label: "Advertised Model", value: meter.modelString)
21
+                MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description)
22
+                if meter.modelNumber != 0 {
23
+                    MeterInfoRowView(label: "Model Identifier", value: "\(meter.modelNumber)")
24
+                }
25
+                MeterInfoRowView(label: "Working Voltage", value: meter.documentedWorkingVoltage)
26
+                MeterInfoRowView(label: "Temperature Unit", value: meter.temperatureUnitDescription)
27
+                MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
28
+                MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
29
+                if meter.operationalState == .dataIsAvailable {
30
+                    if !meter.firmwareVersion.isEmpty {
31
+                        MeterInfoRowView(label: "Firmware", value: meter.firmwareVersion)
32
+                    }
33
+                    if meter.serialNumber != 0 {
34
+                        MeterInfoRowView(label: "Serial", value: "\(meter.serialNumber)")
35
+                    }
36
+                    if meter.bootCount != 0 {
37
+                        MeterInfoRowView(label: "Boot Count", value: "\(meter.bootCount)")
38
+                    }
39
+                } else {
40
+                    Text("Connect to the meter to load firmware, serial, and boot details.")
41
+                        .font(.footnote)
42
+                        .foregroundColor(.secondary)
43
+                        .multilineTextAlignment(.leading)
44
+                }
45
+            }
46
+
47
+        }
48
+        .padding(.horizontal, 12)
49
+    }
50
+
51
+    private func meterHistoryText(for date: Date?) -> String {
52
+        guard let date else {
53
+            return "Never"
54
+        }
55
+        return date.format(as: "yyyy-MM-dd HH:mm")
56
+    }
57
+}
+59 -0
USB Meter/Views/Meter/Tabs/Live/MeterLiveTabView.swift
@@ -0,0 +1,59 @@
1
+//
2
+//  MeterLiveTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterLiveTabView: View {
9
+    let size: CGSize
10
+    let isLandscape: Bool
11
+
12
+    private let pageHorizontalPadding: CGFloat = 12
13
+    private let pageVerticalPadding: CGFloat = 12
14
+    private let contentCardPadding: CGFloat = 16
15
+
16
+    var body: some View {
17
+        Group {
18
+            if isLandscape {
19
+                landscapeFace {
20
+                    MeterLiveContentView(compactLayout: true, availableSize: size)
21
+                        .padding(contentCardPadding)
22
+                        .frame(maxWidth: .infinity, alignment: .topLeading)
23
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
24
+                }
25
+            } else {
26
+                portraitFace {
27
+                    MeterLiveContentView(compactLayout: prefersCompactPortraitLayout, availableSize: size)
28
+                        .padding(contentCardPadding)
29
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
30
+                }
31
+            }
32
+        }
33
+    }
34
+
35
+    @EnvironmentObject private var meter: Meter
36
+
37
+    private var prefersCompactPortraitLayout: Bool {
38
+        size.height < 760 || size.width < 380
39
+    }
40
+
41
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
42
+        ScrollView {
43
+            content()
44
+                .frame(maxWidth: .infinity, alignment: .topLeading)
45
+                .padding(.horizontal, pageHorizontalPadding)
46
+                .padding(.vertical, pageVerticalPadding)
47
+        }
48
+    }
49
+
50
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
51
+        ScrollView {
52
+            content()
53
+                .frame(maxWidth: .infinity, alignment: .topLeading)
54
+                .padding(.horizontal, pageHorizontalPadding)
55
+                .padding(.vertical, pageVerticalPadding)
56
+        }
57
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
58
+    }
59
+}
+60 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/LoadResistanceIconView.swift
@@ -0,0 +1,60 @@
1
+//
2
+//  LoadResistanceIconView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct LoadResistanceIconView: View {
9
+    let color: Color
10
+
11
+    var body: some View {
12
+        GeometryReader { proxy in
13
+            let width = proxy.size.width
14
+            let height = proxy.size.height
15
+            let midY = height / 2
16
+            let startX = width * 0.10
17
+            let endX = width * 0.90
18
+            let boxMinX = width * 0.28
19
+            let boxMaxX = width * 0.72
20
+            let boxHeight = height * 0.34
21
+            let boxRect = CGRect(
22
+                x: boxMinX,
23
+                y: midY - (boxHeight / 2),
24
+                width: boxMaxX - boxMinX,
25
+                height: boxHeight
26
+            )
27
+            let strokeWidth = max(1.2, height * 0.055)
28
+
29
+            ZStack {
30
+                Path { path in
31
+                    path.move(to: CGPoint(x: startX, y: midY))
32
+                    path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
33
+                    path.move(to: CGPoint(x: boxRect.maxX, y: midY))
34
+                    path.addLine(to: CGPoint(x: endX, y: midY))
35
+                }
36
+                .stroke(
37
+                    color,
38
+                    style: StrokeStyle(
39
+                        lineWidth: strokeWidth,
40
+                        lineCap: .round,
41
+                        lineJoin: .round
42
+                    )
43
+                )
44
+
45
+                Path { path in
46
+                    path.addRect(boxRect)
47
+                }
48
+                .stroke(
49
+                    color,
50
+                    style: StrokeStyle(
51
+                        lineWidth: strokeWidth,
52
+                        lineCap: .round,
53
+                        lineJoin: .round
54
+                    )
55
+                )
56
+            }
57
+        }
58
+        .padding(4)
59
+    }
60
+}
+692 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -0,0 +1,692 @@
1
+//
2
+//  MeterLiveContentView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 09/03/2020.
6
+//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
+//
8
+
9
+import SwiftUI
10
+
11
+struct MeterLiveContentView: View {
12
+    @EnvironmentObject private var meter: Meter
13
+    @State private var powerAverageSheetVisibility = false
14
+    @State private var rssiHistorySheetVisibility = false
15
+    var compactLayout: Bool = false
16
+    var availableSize: CGSize? = nil
17
+
18
+    var body: some View {
19
+        VStack(alignment: .leading, spacing: 16) {
20
+            HStack {
21
+                Text("Live Data")
22
+                    .font(.headline)
23
+                Spacer()
24
+                statusBadge
25
+            }
26
+
27
+            MeterInfoCardView(title: "Detected Meter", tint: .indigo) {
28
+                MeterInfoRowView(label: "Name", value: meter.name.isEmpty ? "Meter" : meter.name)
29
+                MeterInfoRowView(label: "Model", value: meter.deviceModelSummary)
30
+                MeterInfoRowView(label: "Advertised Model", value: meter.modelString)
31
+                MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description)
32
+                MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
33
+                MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
34
+            }
35
+            .frame(maxWidth: .infinity, alignment: .leading)
36
+
37
+            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
38
+                if shouldShowVoltageCard {
39
+                    liveMetricCard(
40
+                        title: "Voltage",
41
+                        symbol: "bolt.fill",
42
+                        color: .green,
43
+                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
44
+                        range: metricRange(
45
+                            min: meter.measurements.voltage.context.minValue,
46
+                            max: meter.measurements.voltage.context.maxValue,
47
+                            unit: "V"
48
+                        )
49
+                    )
50
+                }
51
+
52
+                if shouldShowCurrentCard {
53
+                    liveMetricCard(
54
+                        title: "Current",
55
+                        symbol: "waveform.path.ecg",
56
+                        color: .blue,
57
+                        value: "\(meter.current.format(decimalDigits: 3)) A",
58
+                        range: metricRange(
59
+                            min: meter.measurements.current.context.minValue,
60
+                            max: meter.measurements.current.context.maxValue,
61
+                            unit: "A"
62
+                        )
63
+                    )
64
+                }
65
+
66
+                if shouldShowPowerCard {
67
+                    liveMetricCard(
68
+                        title: "Power",
69
+                        symbol: "flame.fill",
70
+                        color: .pink,
71
+                        value: "\(meter.power.format(decimalDigits: 3)) W",
72
+                        range: metricRange(
73
+                            min: meter.measurements.power.context.minValue,
74
+                            max: meter.measurements.power.context.maxValue,
75
+                            unit: "W"
76
+                        ),
77
+                        action: {
78
+                            powerAverageSheetVisibility = true
79
+                        }
80
+                    )
81
+                }
82
+
83
+                if shouldShowEnergyCard {
84
+                    liveMetricCard(
85
+                        title: "Energy",
86
+                        symbol: "battery.100.bolt",
87
+                        color: .teal,
88
+                        value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
89
+                        detailText: "Buffered accumulated energy"
90
+                    )
91
+                }
92
+
93
+                if shouldShowTemperatureCard {
94
+                    liveMetricCard(
95
+                        title: "Temperature",
96
+                        symbol: "thermometer.medium",
97
+                        color: .orange,
98
+                        value: meter.primaryTemperatureDescription,
99
+                        range: temperatureRange(
100
+                            min: meter.measurements.temperature.context.minValue,
101
+                            max: meter.measurements.temperature.context.maxValue
102
+                        )
103
+                    )
104
+                }
105
+
106
+                if shouldShowLoadCard {
107
+                    liveMetricCard(
108
+                        title: "Load",
109
+                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
110
+                        color: .yellow,
111
+                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
112
+                        detailText: "Measured resistance"
113
+                    )
114
+                }
115
+
116
+                liveMetricCard(
117
+                    title: "RSSI",
118
+                    symbol: "dot.radiowaves.left.and.right",
119
+                    color: .mint,
120
+                    value: "\(meter.btSerial.averageRSSI) dBm",
121
+                    range: metricRange(
122
+                        min: meter.measurements.rssi.context.minValue,
123
+                        max: meter.measurements.rssi.context.maxValue,
124
+                        unit: "dBm",
125
+                        decimalDigits: 0
126
+                    ),
127
+                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
128
+                    action: {
129
+                        rssiHistorySheetVisibility = true
130
+                    }
131
+                )
132
+
133
+                if meter.supportsChargerDetection && hasLiveMetrics {
134
+                    liveMetricCard(
135
+                        title: "Detected Charger",
136
+                        symbol: "powerplug.fill",
137
+                        color: .indigo,
138
+                        value: meter.chargerTypeDescription,
139
+                        detailText: "Source handshake",
140
+                        valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
141
+                        valueLineLimit: 2,
142
+                        valueMonospacedDigits: false,
143
+                        valueMinimumScaleFactor: 0.72
144
+                    )
145
+                }
146
+            }
147
+        }
148
+        .frame(maxWidth: .infinity, alignment: .topLeading)
149
+        .sheet(isPresented: $powerAverageSheetVisibility) {
150
+            PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
151
+                .environmentObject(meter.measurements)
152
+        }
153
+        .sheet(isPresented: $rssiHistorySheetVisibility) {
154
+            RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
155
+                .environmentObject(meter.measurements)
156
+        }
157
+    }
158
+
159
+    private var hasLiveMetrics: Bool {
160
+        meter.operationalState == .dataIsAvailable
161
+    }
162
+
163
+    private var shouldShowVoltageCard: Bool {
164
+        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
165
+    }
166
+
167
+    private var shouldShowCurrentCard: Bool {
168
+        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
169
+    }
170
+
171
+    private var shouldShowPowerCard: Bool {
172
+        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
173
+    }
174
+
175
+    private var shouldShowEnergyCard: Bool {
176
+        hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite
177
+    }
178
+
179
+    private var shouldShowTemperatureCard: Bool {
180
+        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
181
+    }
182
+
183
+    private var liveBufferedEnergyValue: Double {
184
+        meter.measurements.energy.samplePoints.last?.value ?? 0
185
+    }
186
+
187
+    private var shouldShowLoadCard: Bool {
188
+        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
189
+    }
190
+
191
+    private var liveMetricColumns: [GridItem] {
192
+        if compactLayout {
193
+            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
194
+        }
195
+
196
+        return [GridItem(.flexible()), GridItem(.flexible())]
197
+    }
198
+
199
+    private var statusBadge: some View {
200
+        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
201
+            .font(.caption.weight(.semibold))
202
+            .padding(.horizontal, 10)
203
+            .padding(.vertical, 6)
204
+            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
205
+            .meterCard(
206
+                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
207
+                fillOpacity: 0.12,
208
+                strokeOpacity: 0.16,
209
+                cornerRadius: 999
210
+            )
211
+    }
212
+
213
+    private var showsCompactMetricRange: Bool {
214
+        compactLayout && (availableSize?.height ?? 0) >= 380
215
+    }
216
+
217
+    private var shouldShowMetricRange: Bool {
218
+        !compactLayout || showsCompactMetricRange
219
+    }
220
+
221
+    private func liveMetricCard(
222
+        title: String,
223
+        symbol: String? = nil,
224
+        customSymbol: AnyView? = nil,
225
+        color: Color,
226
+        value: String,
227
+        range: MeterLiveMetricRange? = nil,
228
+        detailText: String? = nil,
229
+        valueFont: Font? = nil,
230
+        valueLineLimit: Int = 1,
231
+        valueMonospacedDigits: Bool = true,
232
+        valueMinimumScaleFactor: CGFloat = 0.85,
233
+        action: (() -> Void)? = nil
234
+    ) -> some View {
235
+        let cardContent = VStack(alignment: .leading, spacing: 10) {
236
+            HStack(spacing: compactLayout ? 8 : 10) {
237
+                Group {
238
+                    if let customSymbol {
239
+                        customSymbol
240
+                    } else if let symbol {
241
+                        Image(systemName: symbol)
242
+                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
243
+                            .foregroundColor(color)
244
+                    }
245
+                }
246
+                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
247
+                .background(Circle().fill(color.opacity(0.12)))
248
+
249
+                Text(title)
250
+                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
251
+                    .foregroundColor(.secondary)
252
+                    .lineLimit(1)
253
+
254
+                Spacer(minLength: 0)
255
+            }
256
+
257
+            Group {
258
+                if valueMonospacedDigits {
259
+                    Text(value)
260
+                        .monospacedDigit()
261
+                } else {
262
+                    Text(value)
263
+                }
264
+            }
265
+            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
266
+            .lineLimit(valueLineLimit)
267
+            .minimumScaleFactor(valueMinimumScaleFactor)
268
+
269
+            if shouldShowMetricRange {
270
+                if let range {
271
+                    metricRangeTable(range)
272
+                } else if let detailText, !detailText.isEmpty {
273
+                    Text(detailText)
274
+                        .font(.caption)
275
+                        .foregroundColor(.secondary)
276
+                        .lineLimit(2)
277
+                }
278
+            }
279
+        }
280
+        .frame(
281
+            maxWidth: .infinity,
282
+            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
283
+            alignment: .leading
284
+        )
285
+        .padding(compactLayout ? 12 : 16)
286
+        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
287
+
288
+        if let action {
289
+            return AnyView(
290
+                Button(action: action) {
291
+                    cardContent
292
+                }
293
+                .buttonStyle(.plain)
294
+            )
295
+        }
296
+
297
+        return AnyView(cardContent)
298
+    }
299
+
300
+    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
301
+        VStack(alignment: .leading, spacing: 4) {
302
+            HStack(spacing: 12) {
303
+                Text(range.minLabel)
304
+                Spacer(minLength: 0)
305
+                Text(range.maxLabel)
306
+            }
307
+            .font(.caption2.weight(.semibold))
308
+            .foregroundColor(.secondary)
309
+
310
+            HStack(spacing: 12) {
311
+                Text(range.minValue)
312
+                    .monospacedDigit()
313
+                Spacer(minLength: 0)
314
+                Text(range.maxValue)
315
+                    .monospacedDigit()
316
+            }
317
+            .font(.caption.weight(.medium))
318
+            .foregroundColor(.primary)
319
+        }
320
+    }
321
+
322
+    private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
323
+        guard min.isFinite, max.isFinite else { return nil }
324
+
325
+        return MeterLiveMetricRange(
326
+            minLabel: "Min",
327
+            maxLabel: "Max",
328
+            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
329
+            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
330
+        )
331
+    }
332
+
333
+    private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
334
+        guard min.isFinite, max.isFinite else { return nil }
335
+
336
+        let unitSuffix = temperatureUnitSuffix()
337
+
338
+        return MeterLiveMetricRange(
339
+            minLabel: "Min",
340
+            maxLabel: "Max",
341
+            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
342
+            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
343
+        )
344
+    }
345
+
346
+    private func meterHistoryText(for date: Date?) -> String {
347
+        guard let date else {
348
+            return "Never"
349
+        }
350
+        return date.format(as: "yyyy-MM-dd HH:mm")
351
+    }
352
+
353
+    private func temperatureUnitSuffix() -> String {
354
+        if meter.supportsManualTemperatureUnitSelection {
355
+            return "°"
356
+        }
357
+
358
+        let locale = Locale.autoupdatingCurrent
359
+        if #available(iOS 16.0, *) {
360
+            switch locale.measurementSystem {
361
+            case .us:
362
+                return "°F"
363
+            default:
364
+                return "°C"
365
+            }
366
+        }
367
+
368
+        let regionCode = locale.regionCode ?? ""
369
+        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
370
+        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
371
+    }
372
+}
373
+
374
+private struct PowerAverageSheetView: View {
375
+    @EnvironmentObject private var measurements: Measurements
376
+
377
+    @Binding var visibility: Bool
378
+
379
+    @State private var selectedSampleCount: Int = 20
380
+
381
+    var body: some View {
382
+        let bufferedSamples = measurements.powerSampleCount()
383
+
384
+        NavigationView {
385
+            ScrollView {
386
+                VStack(alignment: .leading, spacing: 14) {
387
+                    VStack(alignment: .leading, spacing: 8) {
388
+                        Text("Power Average")
389
+                            .font(.system(.title3, design: .rounded).weight(.bold))
390
+                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
391
+                            .font(.footnote)
392
+                            .foregroundColor(.secondary)
393
+                    }
394
+                    .padding(18)
395
+                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
396
+
397
+                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
398
+                        if bufferedSamples == 0 {
399
+                            Text("No power samples are available yet.")
400
+                                .font(.footnote)
401
+                                .foregroundColor(.secondary)
402
+                        } else {
403
+                            VStack(alignment: .leading, spacing: 14) {
404
+                                VStack(alignment: .leading, spacing: 8) {
405
+                                    Text("Values used")
406
+                                        .font(.subheadline.weight(.semibold))
407
+
408
+                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
409
+                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
410
+                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
411
+                                        }
412
+                                    }
413
+                                    .pickerStyle(.menu)
414
+                                }
415
+
416
+                                VStack(alignment: .leading, spacing: 6) {
417
+                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
418
+                                        .font(.system(.title2, design: .rounded).weight(.bold))
419
+                                        .monospacedDigit()
420
+
421
+                                    Text("Buffered samples: \(bufferedSamples)")
422
+                                        .font(.caption)
423
+                                        .foregroundColor(.secondary)
424
+                                }
425
+                            }
426
+                        }
427
+                    }
428
+
429
+                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
430
+                        Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
431
+                            .font(.footnote)
432
+                            .foregroundColor(.secondary)
433
+
434
+                        Button("Reset Buffer") {
435
+                            measurements.resetSeries()
436
+                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
437
+                        }
438
+                        .foregroundColor(.red)
439
+                    }
440
+                }
441
+                .padding()
442
+            }
443
+            .background(
444
+                LinearGradient(
445
+                    colors: [.pink.opacity(0.14), Color.clear],
446
+                    startPoint: .topLeading,
447
+                    endPoint: .bottomTrailing
448
+                )
449
+                .ignoresSafeArea()
450
+            )
451
+            .navigationBarItems(
452
+                leading: Button("Done") { visibility.toggle() }
453
+            )
454
+            .navigationBarTitle("Power", displayMode: .inline)
455
+        }
456
+        .navigationViewStyle(StackNavigationViewStyle())
457
+        .onAppear {
458
+            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
459
+        }
460
+        .onChange(of: bufferedSamples) { newValue in
461
+            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
462
+        }
463
+    }
464
+
465
+    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
466
+        guard bufferedSamples > 0 else { return [] }
467
+
468
+        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
469
+        return (filtered + [bufferedSamples]).sorted()
470
+    }
471
+
472
+    private func defaultSampleCount(bufferedSamples: Int) -> Int {
473
+        guard bufferedSamples > 0 else { return 20 }
474
+        return min(20, bufferedSamples)
475
+    }
476
+
477
+    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
478
+        Binding(
479
+            get: {
480
+                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
481
+                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
482
+                if availableOptions.contains(selectedSampleCount) {
483
+                    return selectedSampleCount
484
+                }
485
+                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
486
+            },
487
+            set: { newValue in
488
+                selectedSampleCount = newValue
489
+            }
490
+        )
491
+    }
492
+
493
+    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
494
+        if option == bufferedSamples {
495
+            return "All (\(option))"
496
+        }
497
+        return "\(option) values"
498
+    }
499
+
500
+    private func averagePowerLabel(bufferedSamples: Int) -> String {
501
+        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
502
+            return "No data"
503
+        }
504
+
505
+        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
506
+        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
507
+    }
508
+}
509
+
510
+private struct RSSIHistorySheetView: View {
511
+    @EnvironmentObject private var measurements: Measurements
512
+
513
+    @Binding var visibility: Bool
514
+
515
+    private let xLabels: Int = 4
516
+    private let yLabels: Int = 4
517
+
518
+    var body: some View {
519
+        let points = measurements.rssi.points
520
+        let samplePoints = measurements.rssi.samplePoints
521
+        let chartContext = buildChartContext(for: samplePoints)
522
+
523
+        NavigationView {
524
+            ScrollView {
525
+                VStack(alignment: .leading, spacing: 14) {
526
+                    VStack(alignment: .leading, spacing: 8) {
527
+                        Text("RSSI History")
528
+                            .font(.system(.title3, design: .rounded).weight(.bold))
529
+                        Text("Signal strength captured over time while the meter stays connected.")
530
+                            .font(.footnote)
531
+                            .foregroundColor(.secondary)
532
+                    }
533
+                    .padding(18)
534
+                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
535
+
536
+                    if samplePoints.isEmpty {
537
+                        Text("No RSSI samples have been captured yet.")
538
+                            .font(.footnote)
539
+                            .foregroundColor(.secondary)
540
+                            .padding(18)
541
+                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
542
+                    } else {
543
+                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
544
+                            VStack(alignment: .leading, spacing: 12) {
545
+                                HStack(spacing: 12) {
546
+                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
547
+                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
548
+                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
549
+                                }
550
+
551
+                                HStack(spacing: 8) {
552
+                                    rssiYAxisView(context: chartContext)
553
+                                        .frame(width: 52, height: 220)
554
+
555
+                                    ZStack {
556
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
557
+                                            .fill(Color.primary.opacity(0.05))
558
+
559
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
560
+                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
561
+
562
+                                        rssiHorizontalGuides(context: chartContext)
563
+                                        rssiVerticalGuides(context: chartContext)
564
+                                        Chart(points: points, context: chartContext, strokeColor: .mint)
565
+                                            .opacity(0.82)
566
+                                    }
567
+                                    .frame(maxWidth: .infinity)
568
+                                    .frame(height: 220)
569
+                                }
570
+
571
+                                rssiXAxisLabelsView(context: chartContext)
572
+                                    .frame(height: 28)
573
+                            }
574
+                        }
575
+                    }
576
+                }
577
+                .padding()
578
+            }
579
+            .background(
580
+                LinearGradient(
581
+                    colors: [.mint.opacity(0.14), Color.clear],
582
+                    startPoint: .topLeading,
583
+                    endPoint: .bottomTrailing
584
+                )
585
+                .ignoresSafeArea()
586
+            )
587
+            .navigationBarItems(
588
+                leading: Button("Done") { visibility.toggle() }
589
+            )
590
+            .navigationBarTitle("RSSI", displayMode: .inline)
591
+        }
592
+        .navigationViewStyle(StackNavigationViewStyle())
593
+    }
594
+
595
+    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
596
+        let context = ChartContext()
597
+        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
598
+        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
599
+        let minimumValue = samplePoints.map(\.value).min() ?? -100
600
+        let maximumValue = samplePoints.map(\.value).max() ?? -40
601
+        let padding = max((maximumValue - minimumValue) * 0.12, 4)
602
+
603
+        context.setBounds(
604
+            xMin: CGFloat(lowerBound.timeIntervalSince1970),
605
+            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
606
+            yMin: CGFloat(minimumValue - padding),
607
+            yMax: CGFloat(maximumValue + padding)
608
+        )
609
+        return context
610
+    }
611
+
612
+    private func signalSummaryChip(title: String, value: String) -> some View {
613
+        VStack(alignment: .leading, spacing: 4) {
614
+            Text(title)
615
+                .font(.caption.weight(.semibold))
616
+                .foregroundColor(.secondary)
617
+            Text(value)
618
+                .font(.subheadline.weight(.bold))
619
+                .monospacedDigit()
620
+        }
621
+        .padding(.horizontal, 12)
622
+        .padding(.vertical, 10)
623
+        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
624
+    }
625
+
626
+    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
627
+        let labels = (1...xLabels).map {
628
+            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
629
+        }
630
+
631
+        return HStack {
632
+            ForEach(Array(labels.enumerated()), id: \.offset) { item in
633
+                Text(item.element)
634
+                    .font(.caption2.weight(.semibold))
635
+                    .monospacedDigit()
636
+                    .frame(maxWidth: .infinity)
637
+            }
638
+        }
639
+        .foregroundColor(.secondary)
640
+    }
641
+
642
+    private func rssiYAxisView(context: ChartContext) -> some View {
643
+        VStack(spacing: 0) {
644
+            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
645
+                Spacer(minLength: 0)
646
+                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
647
+                    .font(.caption2.weight(.semibold))
648
+                    .monospacedDigit()
649
+                    .foregroundColor(.primary)
650
+                Spacer(minLength: 0)
651
+            }
652
+        }
653
+        .padding(.vertical, 12)
654
+        .background(
655
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
656
+                .fill(Color.mint.opacity(0.12))
657
+        )
658
+        .overlay(
659
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
660
+                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
661
+        )
662
+    }
663
+
664
+    private func rssiHorizontalGuides(context: ChartContext) -> some View {
665
+        GeometryReader { geometry in
666
+            Path { path in
667
+                for labelIndex in 1...yLabels {
668
+                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
669
+                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
670
+                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
671
+                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
672
+                }
673
+            }
674
+            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
675
+        }
676
+    }
677
+
678
+    private func rssiVerticalGuides(context: ChartContext) -> some View {
679
+        GeometryReader { geometry in
680
+            Path { path in
681
+                for labelIndex in 2..<xLabels {
682
+                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
683
+                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
684
+                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
685
+                    path.move(to: CGPoint(x: x, y: 0))
686
+                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
687
+                }
688
+            }
689
+            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
690
+        }
691
+    }
692
+}
+13 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveMetricRange.swift
@@ -0,0 +1,13 @@
1
+//
2
+//  MeterLiveMetricRange.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+
8
+struct MeterLiveMetricRange {
9
+    let minLabel: String
10
+    let maxLabel: String
11
+    let minValue: String
12
+    let maxValue: String
13
+}
+174 -0
USB Meter/Views/Meter/Tabs/Settings/MeterSettingsTabView.swift
@@ -0,0 +1,174 @@
1
+//
2
+//  MeterSettingsTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterSettingsTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let isMacIPadApp: Bool
12
+    let onBackToHome: () -> Void
13
+
14
+    @State private var editingName = false
15
+    @State private var editingScreenTimeout = false
16
+    @State private var editingScreenBrightness = false
17
+
18
+    var body: some View {
19
+        VStack(spacing: 0) {
20
+            if isMacIPadApp {
21
+                settingsMacHeader
22
+            }
23
+            ScrollView {
24
+                VStack(spacing: 14) {
25
+                    settingsCard(title: "Name", tint: meter.color) {
26
+                        HStack {
27
+                            Spacer()
28
+                            if !editingName {
29
+                                Text(meter.name)
30
+                                    .foregroundColor(.secondary)
31
+                            }
32
+                            ChevronView(rotate: $editingName)
33
+                        }
34
+                        if editingName {
35
+                            MeterNameEditorView(editingName: $editingName, newName: meter.name)
36
+                        }
37
+                    }
38
+
39
+                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
40
+                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
41
+                            Text("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.")
42
+                                .font(.footnote)
43
+                                .foregroundColor(.secondary)
44
+                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
45
+                                ForEach(TemperatureUnitPreference.allCases) { unit in
46
+                                    Text(unit.title).tag(unit)
47
+                                }
48
+                            }
49
+                            .pickerStyle(SegmentedPickerStyle())
50
+                        }
51
+                    }
52
+
53
+                    if meter.operationalState == .dataIsAvailable && meter.model == .TC66C {
54
+                        settingsCard(title: "Screen Reporting", tint: .orange) {
55
+                            MeterInfoRowView(label: "Current Screen", value: "Not Reported")
56
+                            Text("TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.")
57
+                                .font(.footnote)
58
+                                .foregroundColor(.secondary)
59
+                        }
60
+                    }
61
+
62
+                    if meter.operationalState == .dataIsAvailable {
63
+                        settingsCard(
64
+                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
65
+                            tint: .indigo
66
+                        ) {
67
+                            if meter.reportsCurrentScreenIndex {
68
+                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
69
+                                    .font(.footnote)
70
+                                    .foregroundColor(.secondary)
71
+                            } else {
72
+                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
73
+                                    .font(.footnote)
74
+                                    .foregroundColor(.secondary)
75
+                            }
76
+
77
+                            MeterScreenControlsView(showsHeader: false)
78
+                        }
79
+                    }
80
+
81
+                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
82
+                        settingsCard(title: "Screen Timeout", tint: .purple) {
83
+                            HStack {
84
+                                Spacer()
85
+                                if !editingScreenTimeout {
86
+                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
87
+                                        .foregroundColor(.secondary)
88
+                                }
89
+                                ChevronView(rotate: $editingScreenTimeout)
90
+                            }
91
+                            if editingScreenTimeout {
92
+                                ScreenTimeoutEditorView()
93
+                            }
94
+                        }
95
+
96
+                        settingsCard(title: "Screen Brightness", tint: .yellow) {
97
+                            HStack {
98
+                                Spacer()
99
+                                if !editingScreenBrightness {
100
+                                    Text("\(meter.screenBrightness)")
101
+                                        .foregroundColor(.secondary)
102
+                                }
103
+                                ChevronView(rotate: $editingScreenBrightness)
104
+                            }
105
+                            if editingScreenBrightness {
106
+                                ScreenBrightnessEditorView()
107
+                            }
108
+                        }
109
+                    }
110
+                }
111
+                .padding()
112
+            }
113
+            .background(
114
+                LinearGradient(
115
+                    colors: [meter.color.opacity(0.14), Color.clear],
116
+                    startPoint: .topLeading,
117
+                    endPoint: .bottomTrailing
118
+                )
119
+                .ignoresSafeArea()
120
+            )
121
+        }
122
+    }
123
+
124
+    private var settingsMacHeader: some View {
125
+        HStack(spacing: 12) {
126
+            Button(action: onBackToHome) {
127
+                HStack(spacing: 4) {
128
+                    Image(systemName: "chevron.left")
129
+                        .font(.body.weight(.semibold))
130
+                    Text("Back")
131
+                }
132
+                .foregroundColor(.accentColor)
133
+            }
134
+            .buttonStyle(.plain)
135
+
136
+            Text("Meter Settings")
137
+                .font(.headline)
138
+                .lineLimit(1)
139
+
140
+            Spacer()
141
+
142
+            if meter.operationalState > .notPresent {
143
+                RSSIView(RSSI: meter.btSerial.averageRSSI)
144
+                    .frame(width: 18, height: 18)
145
+            }
146
+        }
147
+        .padding(.horizontal, 16)
148
+        .padding(.vertical, 10)
149
+        .background(
150
+            Rectangle()
151
+                .fill(.ultraThinMaterial)
152
+                .ignoresSafeArea(edges: .top)
153
+        )
154
+        .overlay(alignment: .bottom) {
155
+            Rectangle()
156
+                .fill(Color.secondary.opacity(0.12))
157
+                .frame(height: 1)
158
+        }
159
+    }
160
+
161
+    private func settingsCard<Content: View>(
162
+        title: String,
163
+        tint: Color,
164
+        @ViewBuilder content: () -> Content
165
+    ) -> some View {
166
+        VStack(alignment: .leading, spacing: 12) {
167
+            Text(title)
168
+                .font(.headline)
169
+            content()
170
+        }
171
+        .padding(18)
172
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
173
+    }
174
+}
+30 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterCurrentScreenSummaryView.swift
@@ -0,0 +1,30 @@
1
+//
2
+//  MeterCurrentScreenSummaryView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct MeterCurrentScreenSummaryView: View {
13
+    let reportsCurrentScreenIndex: Bool
14
+    let currentScreenDescription: String
15
+    let isExpandedCompactLayout: Bool
16
+
17
+    var body: some View {
18
+        if reportsCurrentScreenIndex {
19
+            Text(currentScreenDescription)
20
+                .font((isExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold))
21
+                .multilineTextAlignment(.center)
22
+        } else {
23
+            VStack {
24
+                Image(systemName: "questionmark.square.dashed")
25
+                    .font(.system(size: isExpandedCompactLayout ? 30 : 24, weight: .semibold))
26
+                    .foregroundColor(.secondary)
27
+            }
28
+        }
29
+    }
30
+}
+24 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterNameEditorView.swift
@@ -0,0 +1,24 @@
1
+//
2
+//  MeterNameEditorView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterNameEditorView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    @Binding var editingName: Bool
12
+    @State var newName: String
13
+
14
+    var body: some View {
15
+        TextField("Name", text: self.$newName, onCommit: {
16
+            self.meter.name = self.newName
17
+            self.editingName = false
18
+        })
19
+        .textFieldStyle(RoundedBorderTextFieldStyle())
20
+        .lineLimit(1)
21
+        .disableAutocorrection(true)
22
+        .multilineTextAlignment(.center)
23
+    }
24
+}
+36 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterScreenControlButtonView.swift
@@ -0,0 +1,36 @@
1
+//
2
+//  MeterScreenControlButtonView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct MeterScreenControlButtonView: View {
13
+    let title: String
14
+    let symbol: String
15
+    let tint: Color
16
+    let compact: Bool
17
+    let isExpandedCompactLayout: Bool
18
+    let action: () -> Void
19
+
20
+    var body: some View {
21
+        Button(action: action) {
22
+            VStack(spacing: 10) {
23
+                Image(systemName: symbol)
24
+                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
25
+                Text(title)
26
+                    .font(.footnote.weight(.semibold))
27
+                    .multilineTextAlignment(.center)
28
+            }
29
+            .foregroundColor(tint)
30
+            .frame(maxWidth: .infinity, minHeight: compact ? (isExpandedCompactLayout ? 112 : 92) : 68)
31
+            .padding(.horizontal, 8)
32
+            .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14)
33
+        }
34
+        .buttonStyle(.plain)
35
+    }
36
+}
+45 -53
USB Meter/Views/Meter/ControlView.swift → USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterScreenControlsView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  ControlView.swift
2
+//  MeterScreenControlsView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 09/03/2020.
@@ -8,7 +8,7 @@
8 8
 
9 9
 import SwiftUI
10 10
 
11
-struct ControlView: View {
11
+struct MeterScreenControlsView: View {
12 12
     
13 13
     @EnvironmentObject private var meter: Meter
14 14
     var compactLayout: Bool = false
@@ -34,31 +34,41 @@ struct ControlView: View {
34 34
 
35 35
                     VStack(spacing: 12) {
36 36
                         HStack(spacing: 12) {
37
-                            controlButton(
37
+                            MeterScreenControlButtonView(
38 38
                                 title: "Prev",
39 39
                                 symbol: "chevron.left",
40 40
                                 tint: .indigo,
41
+                                compact: true,
42
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
41 43
                                 action: { meter.previousScreen() }
42 44
                             )
43 45
 
44
-                            currentScreenCard
46
+                            MeterCurrentScreenSummaryView(
47
+                                reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
48
+                                currentScreenDescription: meter.currentScreenDescription,
49
+                                isExpandedCompactLayout: usesExpandedCompactLayout
50
+                            )
45 51
                                 .frame(maxWidth: .infinity, minHeight: 112)
46 52
                                 .padding(.horizontal, 14)
47 53
                                 .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
48 54
                         }
49 55
 
50 56
                         HStack(spacing: 12) {
51
-                            controlButton(
57
+                            MeterScreenControlButtonView(
52 58
                                 title: "Rotate",
53 59
                                 symbol: "rotate.right.fill",
54 60
                                 tint: .orange,
61
+                                compact: true,
62
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
55 63
                                 action: { meter.rotateScreen() }
56 64
                             )
57 65
 
58
-                            controlButton(
66
+                            MeterScreenControlButtonView(
59 67
                                 title: "Next",
60 68
                                 symbol: "chevron.right",
61 69
                                 tint: .indigo,
70
+                                compact: true,
71
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
62 72
                                 action: { meter.nextScreen() }
63 73
                             )
64 74
                         }
@@ -67,60 +77,79 @@ struct ControlView: View {
67 77
                     Spacer(minLength: 0)
68 78
                 } else {
69 79
                     HStack(spacing: 10) {
70
-                        controlButton(
80
+                        MeterScreenControlButtonView(
71 81
                             title: "Prev",
72 82
                             symbol: "chevron.left",
73 83
                             tint: .indigo,
84
+                            compact: true,
85
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
74 86
                             action: { meter.previousScreen() }
75 87
                         )
76 88
 
77
-                        currentScreenCard
89
+                        MeterCurrentScreenSummaryView(
90
+                            reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
91
+                            currentScreenDescription: meter.currentScreenDescription,
92
+                            isExpandedCompactLayout: usesExpandedCompactLayout
93
+                        )
78 94
                             .frame(maxWidth: .infinity, minHeight: 82)
79 95
                             .padding(.horizontal, 10)
80 96
                             .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
81 97
 
82
-                        controlButton(
98
+                        MeterScreenControlButtonView(
83 99
                             title: "Rotate",
84 100
                             symbol: "rotate.right.fill",
85 101
                             tint: .orange,
102
+                            compact: true,
103
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
86 104
                             action: { meter.rotateScreen() }
87 105
                         )
88 106
 
89
-                        controlButton(
107
+                        MeterScreenControlButtonView(
90 108
                             title: "Next",
91 109
                             symbol: "chevron.right",
92 110
                             tint: .indigo,
111
+                            compact: true,
112
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
93 113
                             action: { meter.nextScreen() }
94 114
                         )
95 115
                     }
96 116
                 }
97 117
             } else {
98 118
                 HStack(spacing: 12) {
99
-                    controlButton(
119
+                    MeterScreenControlButtonView(
100 120
                         title: "Prev",
101 121
                         symbol: "chevron.left",
102 122
                         tint: .indigo,
123
+                        compact: true,
124
+                        isExpandedCompactLayout: usesExpandedCompactLayout,
103 125
                         action: { meter.previousScreen() }
104 126
                     )
105 127
 
106
-                    currentScreenCard
128
+                    MeterCurrentScreenSummaryView(
129
+                        reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
130
+                        currentScreenDescription: meter.currentScreenDescription,
131
+                        isExpandedCompactLayout: usesExpandedCompactLayout
132
+                    )
107 133
                     .frame(maxWidth: .infinity, minHeight: 92)
108 134
                     .padding(.horizontal, 12)
109 135
                     .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
110 136
 
111
-                    controlButton(
137
+                    MeterScreenControlButtonView(
112 138
                         title: "Next",
113 139
                         symbol: "chevron.right",
114 140
                         tint: .indigo,
141
+                        compact: true,
142
+                        isExpandedCompactLayout: usesExpandedCompactLayout,
115 143
                         action: { meter.nextScreen() }
116 144
                     )
117 145
                 }
118 146
 
119
-                controlButton(
147
+                MeterScreenControlButtonView(
120 148
                     title: "Rotate Screen",
121 149
                     symbol: "rotate.right.fill",
122 150
                     tint: .orange,
123 151
                     compact: false,
152
+                    isExpandedCompactLayout: usesExpandedCompactLayout,
124 153
                     action: { meter.rotateScreen() }
125 154
                 )
126 155
             }
@@ -128,51 +157,14 @@ struct ControlView: View {
128 157
         .frame(maxWidth: .infinity, maxHeight: compactLayout ? .infinity : nil, alignment: .topLeading)
129 158
     }
130 159
 
131
-    @ViewBuilder
132
-    private var currentScreenCard: some View {
133
-        if meter.reportsCurrentScreenIndex {
134
-            Text(meter.currentScreenDescription)
135
-                .font((usesExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold))
136
-                .multilineTextAlignment(.center)
137
-        } else {
138
-            VStack {
139
-                Image(systemName: "questionmark.square.dashed")
140
-                    .font(.system(size: usesExpandedCompactLayout ? 30 : 24, weight: .semibold))
141
-                    .foregroundColor(.secondary)
142
-            }
143
-        }
144
-    }
145
-
146 160
     private var usesExpandedCompactLayout: Bool {
147 161
         compactLayout && (availableSize?.height ?? 0) >= 520
148 162
     }
149 163
 
150
-    private func controlButton(
151
-        title: String,
152
-        symbol: String,
153
-        tint: Color,
154
-        compact: Bool = true,
155
-        action: @escaping () -> Void
156
-    ) -> some View {
157
-        Button(action: action) {
158
-            VStack(spacing: 10) {
159
-                Image(systemName: symbol)
160
-                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
161
-                Text(title)
162
-                    .font(.footnote.weight(.semibold))
163
-                    .multilineTextAlignment(.center)
164
-            }
165
-            .foregroundColor(tint)
166
-            .frame(maxWidth: .infinity, minHeight: compact ? (usesExpandedCompactLayout ? 112 : 92) : 68)
167
-            .padding(.horizontal, 8)
168
-            .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14)
169
-        }
170
-        .buttonStyle(.plain)
171
-    }
172 164
 }
173 165
 
174
-struct ControlView_Previews: PreviewProvider {
166
+struct MeterScreenControlsView_Previews: PreviewProvider {
175 167
     static var previews: some View {
176
-        ControlView()
168
+        MeterScreenControlsView()
177 169
     }
178 170
 }
+19 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/ScreenBrightnessEditorView.swift
@@ -0,0 +1,19 @@
1
+//
2
+//  ScreenBrightnessEditorView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct ScreenBrightnessEditorView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    var body: some View {
12
+        Picker("", selection: self.$meter.screenBrightness) {
13
+            ForEach(0...5, id: \.self) { value in
14
+                Text("\(value)").tag(value)
15
+            }
16
+        }
17
+        .pickerStyle(SegmentedPickerStyle())
18
+    }
19
+}
+20 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/ScreenTimeoutEditorView.swift
@@ -0,0 +1,20 @@
1
+//
2
+//  ScreenTimeoutEditorView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct ScreenTimeoutEditorView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    var body: some View {
12
+        Picker("", selection: self.$meter.screenTimeout) {
13
+            ForEach(1...9, id: \.self) { value in
14
+                Text("\(value)").tag(value)
15
+            }
16
+            Text("Off").tag(0)
17
+        }
18
+        .pickerStyle(SegmentedPickerStyle())
19
+    }
20
+}
+104 -0
USB Meter/Views/MeterDetailView.swift
@@ -0,0 +1,104 @@
1
+import SwiftUI
2
+
3
+struct MeterDetailView: View {
4
+    let meterSummary: AppData.MeterSummary
5
+
6
+    var body: some View {
7
+        ScrollView {
8
+            VStack(spacing: 18) {
9
+                headerCard
10
+                statusCard
11
+                identifiersCard
12
+            }
13
+            .padding()
14
+        }
15
+        .background(
16
+            LinearGradient(
17
+                colors: [meterSummary.tint.opacity(0.18), Color.clear],
18
+                startPoint: .topLeading,
19
+                endPoint: .bottomTrailing
20
+            )
21
+            .ignoresSafeArea()
22
+        )
23
+        .navigationTitle(meterSummary.displayName)
24
+    }
25
+
26
+    private var headerCard: some View {
27
+        VStack(alignment: .leading, spacing: 8) {
28
+            Text(meterSummary.displayName)
29
+                .font(.title2.weight(.semibold))
30
+            Text(meterSummary.modelSummary)
31
+                .font(.subheadline)
32
+                .foregroundColor(.secondary)
33
+            if let advertisedName = meterSummary.advertisedName {
34
+                Text("Advertised as " + advertisedName)
35
+                    .font(.caption2)
36
+                    .foregroundColor(.secondary)
37
+            }
38
+        }
39
+        .frame(maxWidth: .infinity, alignment: .leading)
40
+        .padding(18)
41
+        .meterCard(tint: meterSummary.tint, fillOpacity: 0.22, strokeOpacity: 0.28, cornerRadius: 20)
42
+    }
43
+
44
+    private var statusCard: some View {
45
+        VStack(alignment: .leading, spacing: 10) {
46
+            Text("Status")
47
+                .font(.headline)
48
+            HStack(spacing: 8) {
49
+                Circle()
50
+                    .fill(meterSummary.tint)
51
+                    .frame(width: 10, height: 10)
52
+                Text("Offline")
53
+                    .font(.caption.weight(.semibold))
54
+                    .foregroundColor(.secondary)
55
+            }
56
+            Text("The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics.")
57
+                .font(.caption)
58
+                .foregroundColor(.secondary)
59
+        }
60
+        .frame(maxWidth: .infinity, alignment: .leading)
61
+        .padding(18)
62
+        .meterCard(tint: meterSummary.tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
63
+    }
64
+
65
+    private var identifiersCard: some View {
66
+        VStack(alignment: .leading, spacing: 10) {
67
+            Text("Identifiers")
68
+                .font(.headline)
69
+            infoRow(label: "MAC Address", value: meterSummary.macAddress)
70
+            if let advertisedName = meterSummary.advertisedName {
71
+                infoRow(label: "Advertised as", value: advertisedName)
72
+            }
73
+        }
74
+        .frame(maxWidth: .infinity, alignment: .leading)
75
+        .padding(18)
76
+        .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
77
+    }
78
+
79
+    private func infoRow(label: String, value: String) -> some View {
80
+        HStack {
81
+            Text(label)
82
+            Spacer()
83
+            Text(value)
84
+                .foregroundColor(.secondary)
85
+                .font(.caption)
86
+        }
87
+    }
88
+}
89
+
90
+struct MeterDetailView_Previews: PreviewProvider {
91
+    static var previews: some View {
92
+        MeterDetailView(
93
+            meterSummary: AppData.MeterSummary(
94
+                macAddress: "AA:BB:CC:DD:EE:FF",
95
+                displayName: "Desk Meter",
96
+                modelSummary: "UM25C",
97
+                advertisedName: "UM25C-123",
98
+                lastSeen: Date(),
99
+                lastConnected: Date().addingTimeInterval(-3600),
100
+                meter: nil
101
+            )
102
+        )
103
+    }
104
+}
+82 -0
USB Meter/Views/MeterMappingDebugView.swift
@@ -0,0 +1,82 @@
1
+//  MeterMappingDebugView.swift
2
+//  USB Meter
3
+//
4
+//  Created by Codex on 2026.
5
+//
6
+
7
+import SwiftUI
8
+
9
+struct MeterMappingDebugView: View {
10
+    @State private var records: [MeterNameRecord] = []
11
+    private let store = MeterNameStore.shared
12
+    private let changePublisher = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
13
+
14
+    var body: some View {
15
+        List {
16
+            Section {
17
+                VStack(alignment: .leading, spacing: 8) {
18
+                    Text(store.currentCloudAvailability.helpTitle)
19
+                        .font(.headline)
20
+                    Text("This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS.")
21
+                        .font(.caption)
22
+                        .foregroundColor(.secondary)
23
+                    Text(store.currentCloudAvailability.helpMessage)
24
+                        .font(.caption)
25
+                        .foregroundColor(.secondary)
26
+                }
27
+                .padding(.vertical, 6)
28
+            } header: {
29
+                Text("Sync Status")
30
+            }
31
+
32
+            Section {
33
+                ForEach(records) { record in
34
+                    VStack(alignment: .leading, spacing: 6) {
35
+                        Text(record.customName)
36
+                            .font(.headline)
37
+                        Text(record.macAddress)
38
+                            .font(.caption.monospaced())
39
+                            .foregroundColor(.secondary)
40
+                        HStack {
41
+                            Text("TC66 unit:")
42
+                                .font(.caption.weight(.semibold))
43
+                            Text(record.temperatureUnit)
44
+                                .font(.caption.monospaced())
45
+                                .foregroundColor(.blue)
46
+                        }
47
+                    }
48
+                    .padding(.vertical, 8)
49
+                }
50
+            } header: {
51
+                Text("KVS Meter Mapping")
52
+            }
53
+        }
54
+        .listStyle(.insetGrouped)
55
+        .navigationTitle("Meter Sync Debug")
56
+        .onAppear(perform: reload)
57
+        .onReceive(changePublisher) { _ in reload() }
58
+        .toolbar {
59
+            Button("Refresh") {
60
+                reload()
61
+            }
62
+        }
63
+    }
64
+
65
+    private func reload() {
66
+        records = store.allRecords().map { record in
67
+            MeterNameRecord(
68
+                id: record.id,
69
+                macAddress: record.macAddress,
70
+                customName: record.customName ?? "<unnamed>",
71
+                temperatureUnit: record.temperatureUnit ?? "n/a"
72
+            )
73
+        }
74
+    }
75
+}
76
+
77
+private struct MeterNameRecord: Identifiable {
78
+    let id: String
79
+    let macAddress: String
80
+    let customName: String
81
+    let temperatureUnit: String
82
+}
+0 -3
USB Meter/Views/MeterRowView.swift
@@ -61,9 +61,6 @@ struct MeterRowView: View {
61 61
                     Capsule(style: .continuous)
62 62
                         .stroke(connectivityTint.opacity(0.22), lineWidth: 1)
63 63
                 )
64
-                Text(meter.btSerial.macAddress.description)
65
-                    .font(.caption2)
66
-                    .foregroundColor(.secondary)
67 64
             }
68 65
         }
69 66
         .padding(14)
+75 -0
USB Meter/Views/Sidebar/SidebarList/Components/MeterCardView.swift
@@ -0,0 +1,75 @@
1
+//
2
+//  MeterCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterCardView: View {
9
+    let meterSummary: AppData.MeterSummary
10
+
11
+    var body: some View {
12
+        HStack(spacing: 14) {
13
+            Image(systemName: "sensor.tag.radiowaves.forward.fill")
14
+                .font(.system(size: 18, weight: .semibold))
15
+                .foregroundColor(meterSummary.tint)
16
+                .frame(width: 42, height: 42)
17
+                .background(
18
+                    Circle()
19
+                        .fill(meterSummary.tint.opacity(0.18))
20
+                )
21
+                .overlay(alignment: .bottomTrailing) {
22
+                    Circle()
23
+                        .fill(Color.red)
24
+                        .frame(width: 12, height: 12)
25
+                        .overlay(
26
+                            Circle()
27
+                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
28
+                        )
29
+                }
30
+
31
+            VStack(alignment: .leading, spacing: 4) {
32
+                Text(meterSummary.displayName)
33
+                    .font(.headline)
34
+                Text(meterSummary.modelSummary)
35
+                    .font(.caption)
36
+                    .foregroundColor(.secondary)
37
+                if let advertisedName = meterSummary.advertisedName, advertisedName != meterSummary.modelSummary {
38
+                    Text("Advertised as \(advertisedName)")
39
+                        .font(.caption2)
40
+                        .foregroundColor(.secondary)
41
+                }
42
+            }
43
+
44
+            Spacer()
45
+
46
+            VStack(alignment: .trailing, spacing: 4) {
47
+                HStack(spacing: 6) {
48
+                    Circle()
49
+                        .fill(Color.red)
50
+                        .frame(width: 8, height: 8)
51
+                    Text("Missing")
52
+                        .font(.caption.weight(.semibold))
53
+                        .foregroundColor(.secondary)
54
+                }
55
+                .padding(.horizontal, 10)
56
+                .padding(.vertical, 6)
57
+                .background(
58
+                    Capsule(style: .continuous)
59
+                        .fill(Color.red.opacity(0.12))
60
+                )
61
+                .overlay(
62
+                    Capsule(style: .continuous)
63
+                        .stroke(Color.red.opacity(0.22), lineWidth: 1)
64
+                )
65
+            }
66
+        }
67
+        .padding(14)
68
+        .meterCard(
69
+            tint: meterSummary.tint,
70
+            fillOpacity: 0.16,
71
+            strokeOpacity: 0.22,
72
+            cornerRadius: 18
73
+        )
74
+    }
75
+}
+41 -0
USB Meter/Views/Sidebar/SidebarList/Components/SidebarLinkCardView.swift
@@ -0,0 +1,41 @@
1
+//
2
+//  SidebarLinkCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarLinkCardView: View {
9
+    let title: String
10
+    let subtitle: String?
11
+    let symbol: String
12
+    let tint: Color
13
+
14
+    var body: some View {
15
+        HStack(spacing: 14) {
16
+            Image(systemName: symbol)
17
+                .font(.system(size: 18, weight: .semibold))
18
+                .foregroundColor(tint)
19
+                .frame(width: 42, height: 42)
20
+                .background(Circle().fill(tint.opacity(0.18)))
21
+
22
+            VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 4) {
23
+                Text(title)
24
+                    .font(.headline)
25
+                if let subtitle {
26
+                    Text(subtitle)
27
+                        .font(.caption)
28
+                        .foregroundColor(.secondary)
29
+                }
30
+            }
31
+
32
+            Spacer()
33
+
34
+            Image(systemName: "chevron.right")
35
+                .font(.footnote.weight(.bold))
36
+                .foregroundColor(.secondary)
37
+        }
38
+        .padding(14)
39
+        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
40
+    }
41
+}
+63 -0
USB Meter/Views/Sidebar/SidebarList/Logic/SidebarAutoHelpResolver.swift
@@ -0,0 +1,63 @@
1
+//
2
+//  SidebarAutoHelpResolver.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+import CoreBluetooth
8
+
9
+enum SidebarAutoHelpResolver {
10
+    static func activeReason(
11
+        managerState: CBManagerState,
12
+        cloudAvailability: MeterNameStore.CloudAvailability,
13
+        hasLiveMeters: Bool,
14
+        scanStartedAt: Date?,
15
+        now: Date,
16
+        noDevicesHelpDelay: TimeInterval
17
+    ) -> SidebarHelpReason? {
18
+        if managerState == .unauthorized {
19
+            return .bluetoothPermission
20
+        }
21
+        if shouldPromptForCloudSync(cloudAvailability) {
22
+            return .cloudSyncUnavailable
23
+        }
24
+        if hasWaitedLongEnoughForDevices(
25
+            managerState: managerState,
26
+            hasLiveMeters: hasLiveMeters,
27
+            scanStartedAt: scanStartedAt,
28
+            now: now,
29
+            noDevicesHelpDelay: noDevicesHelpDelay
30
+        ) {
31
+            return .noDevicesDetected
32
+        }
33
+        return nil
34
+    }
35
+
36
+    private static func shouldPromptForCloudSync(_ cloudAvailability: MeterNameStore.CloudAvailability) -> Bool {
37
+        switch cloudAvailability {
38
+        case .noAccount, .error:
39
+            return true
40
+        case .unknown, .available:
41
+            return false
42
+        }
43
+    }
44
+
45
+    private static func hasWaitedLongEnoughForDevices(
46
+        managerState: CBManagerState,
47
+        hasLiveMeters: Bool,
48
+        scanStartedAt: Date?,
49
+        now: Date,
50
+        noDevicesHelpDelay: TimeInterval
51
+    ) -> Bool {
52
+        guard managerState == .poweredOn else {
53
+            return false
54
+        }
55
+        guard hasLiveMeters == false else {
56
+            return false
57
+        }
58
+        guard let scanStartedAt else {
59
+            return false
60
+        }
61
+        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
62
+    }
63
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/ContentSidebarHelpSectionView.swift
@@ -0,0 +1,141 @@
1
+//
2
+//  ContentSidebarHelpSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct ContentSidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
9
+    let activeReason: SidebarHelpReason?
10
+    let isExpanded: Bool
11
+    let bluetoothStatusTint: Color
12
+    let bluetoothStatusText: String
13
+    let cloudSyncHelpTitle: String
14
+    let cloudSyncHelpMessage: String
15
+    let onToggle: () -> Void
16
+    let onOpenSettings: () -> Void
17
+    let bluetoothHelpDestination: BluetoothHelpDestination
18
+    let deviceHelpDestination: DeviceHelpDestination
19
+
20
+    init(
21
+        activeReason: SidebarHelpReason?,
22
+        isExpanded: Bool,
23
+        bluetoothStatusTint: Color,
24
+        bluetoothStatusText: String,
25
+        cloudSyncHelpTitle: String,
26
+        cloudSyncHelpMessage: String,
27
+        onToggle: @escaping () -> Void,
28
+        onOpenSettings: @escaping () -> Void,
29
+        @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination,
30
+        @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination
31
+    ) {
32
+        self.activeReason = activeReason
33
+        self.isExpanded = isExpanded
34
+        self.bluetoothStatusTint = bluetoothStatusTint
35
+        self.bluetoothStatusText = bluetoothStatusText
36
+        self.cloudSyncHelpTitle = cloudSyncHelpTitle
37
+        self.cloudSyncHelpMessage = cloudSyncHelpMessage
38
+        self.onToggle = onToggle
39
+        self.onOpenSettings = onOpenSettings
40
+        self.bluetoothHelpDestination = bluetoothHelpDestination()
41
+        self.deviceHelpDestination = deviceHelpDestination()
42
+    }
43
+
44
+    var body: some View {
45
+        Section(header: Text("Help & Troubleshooting").font(.headline)) {
46
+            Button(action: onToggle) {
47
+                HStack(spacing: 14) {
48
+                    Image(systemName: sectionSymbol)
49
+                        .font(.system(size: 18, weight: .semibold))
50
+                        .foregroundColor(sectionTint)
51
+                        .frame(width: 42, height: 42)
52
+                        .background(Circle().fill(sectionTint.opacity(0.18)))
53
+
54
+                    Text("Help")
55
+                        .font(.headline)
56
+
57
+                    Spacer()
58
+
59
+                    if let activeReason {
60
+                        Text(activeReason.badgeTitle)
61
+                            .font(.caption2.weight(.bold))
62
+                            .foregroundColor(activeReason.tint)
63
+                            .padding(.horizontal, 10)
64
+                            .padding(.vertical, 6)
65
+                            .background(
66
+                                Capsule(style: .continuous)
67
+                                    .fill(activeReason.tint.opacity(0.12))
68
+                            )
69
+                            .overlay(
70
+                                Capsule(style: .continuous)
71
+                                    .stroke(activeReason.tint.opacity(0.22), lineWidth: 1)
72
+                            )
73
+                    }
74
+
75
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
76
+                        .font(.footnote.weight(.bold))
77
+                        .foregroundColor(.secondary)
78
+                }
79
+                .padding(14)
80
+                .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
81
+            }
82
+            .buttonStyle(.plain)
83
+
84
+            if isExpanded {
85
+                if let activeReason {
86
+                    SidebarHelpNoticeCardView(
87
+                        reason: activeReason,
88
+                        cloudSyncHelpTitle: cloudSyncHelpTitle,
89
+                        cloudSyncHelpMessage: cloudSyncHelpMessage
90
+                    )
91
+                }
92
+
93
+                SidebarBluetoothStatusCardView(
94
+                    tint: bluetoothStatusTint,
95
+                    statusText: bluetoothStatusText
96
+                )
97
+
98
+                if activeReason == .cloudSyncUnavailable {
99
+                    Button(action: onOpenSettings) {
100
+                SidebarLinkCardView(
101
+                    title: "Open Settings",
102
+                    subtitle: nil,
103
+                    symbol: "gearshape.fill",
104
+                    tint: .indigo
105
+                )
106
+                    }
107
+                    .buttonStyle(.plain)
108
+                }
109
+
110
+                NavigationLink(destination: bluetoothHelpDestination) {
111
+                SidebarLinkCardView(
112
+                    title: "Bluetooth",
113
+                    subtitle: nil,
114
+                    symbol: "bolt.horizontal.circle.fill",
115
+                    tint: bluetoothStatusTint
116
+                )
117
+                }
118
+                .buttonStyle(.plain)
119
+
120
+                NavigationLink(destination: deviceHelpDestination) {
121
+                SidebarLinkCardView(
122
+                    title: "Device",
123
+                    subtitle: nil,
124
+                    symbol: "questionmark.circle.fill",
125
+                    tint: .orange
126
+                )
127
+                }
128
+                .buttonStyle(.plain)
129
+            }
130
+        }
131
+        .animation(.easeInOut(duration: 0.22), value: isExpanded)
132
+    }
133
+
134
+    private var sectionTint: Color {
135
+        activeReason?.tint ?? .secondary
136
+    }
137
+
138
+    private var sectionSymbol: String {
139
+        activeReason?.symbol ?? "questionmark.circle.fill"
140
+    }
141
+}
+30 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarBluetoothStatusCardView.swift
@@ -0,0 +1,30 @@
1
+//
2
+//  SidebarBluetoothStatusCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarBluetoothStatusCardView: View {
9
+    let tint: Color
10
+    let statusText: String
11
+
12
+    var body: some View {
13
+        VStack(alignment: .leading, spacing: 6) {
14
+            HStack {
15
+                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
16
+                    .font(.footnote.weight(.semibold))
17
+                    .foregroundColor(tint)
18
+                Spacer()
19
+                Text(statusText)
20
+                    .font(.caption.weight(.semibold))
21
+                    .foregroundColor(.secondary)
22
+            }
23
+            Text("Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.")
24
+                .font(.caption2)
25
+                .foregroundColor(.secondary)
26
+        }
27
+        .padding(14)
28
+        .meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18)
29
+    }
30
+}
+47 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarHelpNoticeCardView.swift
@@ -0,0 +1,47 @@
1
+//
2
+//  SidebarHelpNoticeCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarHelpNoticeCardView: View {
9
+    let reason: SidebarHelpReason
10
+    let cloudSyncHelpTitle: String
11
+    let cloudSyncHelpMessage: String
12
+
13
+    var body: some View {
14
+        VStack(alignment: .leading, spacing: 8) {
15
+            Text(helpNoticeTitle)
16
+                .font(.subheadline.weight(.semibold))
17
+            Text(helpNoticeDetail)
18
+                .font(.caption)
19
+                .foregroundColor(.secondary)
20
+        }
21
+        .frame(maxWidth: .infinity, alignment: .leading)
22
+        .padding(14)
23
+        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
24
+    }
25
+
26
+    private var helpNoticeTitle: String {
27
+        switch reason {
28
+        case .bluetoothPermission:
29
+            return "Bluetooth access needs attention"
30
+        case .cloudSyncUnavailable:
31
+            return cloudSyncHelpTitle
32
+        case .noDevicesDetected:
33
+            return "No supported meters found yet"
34
+        }
35
+    }
36
+
37
+    private var helpNoticeDetail: String {
38
+        switch reason {
39
+        case .bluetoothPermission:
40
+            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
41
+        case .cloudSyncUnavailable:
42
+            return cloudSyncHelpMessage
43
+        case .noDevicesDetected:
44
+            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
45
+        }
46
+    }
47
+}
+45 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/SidebarHelpReason.swift
@@ -0,0 +1,45 @@
1
+//
2
+//  SidebarHelpReason.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+enum SidebarHelpReason: String {
9
+    case bluetoothPermission
10
+    case cloudSyncUnavailable
11
+    case noDevicesDetected
12
+
13
+    var tint: Color {
14
+        switch self {
15
+        case .bluetoothPermission:
16
+            return .orange
17
+        case .cloudSyncUnavailable:
18
+            return .indigo
19
+        case .noDevicesDetected:
20
+            return .yellow
21
+        }
22
+    }
23
+
24
+    var symbol: String {
25
+        switch self {
26
+        case .bluetoothPermission:
27
+            return "bolt.horizontal.circle.fill"
28
+        case .cloudSyncUnavailable:
29
+            return "icloud.slash.fill"
30
+        case .noDevicesDetected:
31
+            return "magnifyingglass.circle.fill"
32
+        }
33
+    }
34
+
35
+    var badgeTitle: String {
36
+        switch self {
37
+        case .bluetoothPermission:
38
+            return "Required"
39
+        case .cloudSyncUnavailable:
40
+            return "Sync Off"
41
+        case .noDevicesDetected:
42
+            return "Suggested"
43
+        }
44
+    }
45
+}
+22 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarDebugSectionView.swift
@@ -0,0 +1,22 @@
1
+//
2
+//  SidebarDebugSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarDebugSectionView: View {
9
+    var body: some View {
10
+        Section(header: Text("Debug").font(.headline)) {
11
+            NavigationLink(destination: MeterMappingDebugView()) {
12
+                SidebarLinkCardView(
13
+                    title: "Meter Sync Debug",
14
+                    subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.",
15
+                    symbol: "list.bullet.rectangle",
16
+                    tint: .purple
17
+                )
18
+            }
19
+            .buttonStyle(.plain)
20
+        }
21
+    }
22
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarHelpSectionView.swift
@@ -0,0 +1,141 @@
1
+//
2
+//  SidebarHelpSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
9
+    let activeReason: SidebarHelpReason?
10
+    let isExpanded: Bool
11
+    let bluetoothStatusTint: Color
12
+    let bluetoothStatusText: String
13
+    let cloudSyncHelpTitle: String
14
+    let cloudSyncHelpMessage: String
15
+    let onToggle: () -> Void
16
+    let onOpenSettings: () -> Void
17
+    let bluetoothHelpDestination: BluetoothHelpDestination
18
+    let deviceHelpDestination: DeviceHelpDestination
19
+
20
+    init(
21
+        activeReason: SidebarHelpReason?,
22
+        isExpanded: Bool,
23
+        bluetoothStatusTint: Color,
24
+        bluetoothStatusText: String,
25
+        cloudSyncHelpTitle: String,
26
+        cloudSyncHelpMessage: String,
27
+        onToggle: @escaping () -> Void,
28
+        onOpenSettings: @escaping () -> Void,
29
+        @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination,
30
+        @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination
31
+    ) {
32
+        self.activeReason = activeReason
33
+        self.isExpanded = isExpanded
34
+        self.bluetoothStatusTint = bluetoothStatusTint
35
+        self.bluetoothStatusText = bluetoothStatusText
36
+        self.cloudSyncHelpTitle = cloudSyncHelpTitle
37
+        self.cloudSyncHelpMessage = cloudSyncHelpMessage
38
+        self.onToggle = onToggle
39
+        self.onOpenSettings = onOpenSettings
40
+        self.bluetoothHelpDestination = bluetoothHelpDestination()
41
+        self.deviceHelpDestination = deviceHelpDestination()
42
+    }
43
+
44
+    var body: some View {
45
+        Section(header: Text("Help & Troubleshooting").font(.headline)) {
46
+            Button(action: onToggle) {
47
+                HStack(spacing: 14) {
48
+                    Image(systemName: sectionSymbol)
49
+                        .font(.system(size: 18, weight: .semibold))
50
+                        .foregroundColor(sectionTint)
51
+                        .frame(width: 42, height: 42)
52
+                        .background(Circle().fill(sectionTint.opacity(0.18)))
53
+
54
+                    Text("Help")
55
+                        .font(.headline)
56
+
57
+                    Spacer()
58
+
59
+                    if let activeReason {
60
+                        Text(activeReason.badgeTitle)
61
+                            .font(.caption2.weight(.bold))
62
+                            .foregroundColor(activeReason.tint)
63
+                            .padding(.horizontal, 10)
64
+                            .padding(.vertical, 6)
65
+                            .background(
66
+                                Capsule(style: .continuous)
67
+                                    .fill(activeReason.tint.opacity(0.12))
68
+                            )
69
+                            .overlay(
70
+                                Capsule(style: .continuous)
71
+                                    .stroke(activeReason.tint.opacity(0.22), lineWidth: 1)
72
+                            )
73
+                    }
74
+
75
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
76
+                        .font(.footnote.weight(.bold))
77
+                        .foregroundColor(.secondary)
78
+                }
79
+                .padding(14)
80
+                .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
81
+            }
82
+            .buttonStyle(.plain)
83
+
84
+            if isExpanded {
85
+                if let activeReason {
86
+                    SidebarHelpNoticeCardView(
87
+                        reason: activeReason,
88
+                        cloudSyncHelpTitle: cloudSyncHelpTitle,
89
+                        cloudSyncHelpMessage: cloudSyncHelpMessage
90
+                    )
91
+                }
92
+
93
+                SidebarBluetoothStatusCardView(
94
+                    tint: bluetoothStatusTint,
95
+                    statusText: bluetoothStatusText
96
+                )
97
+
98
+                if activeReason == .cloudSyncUnavailable {
99
+                    Button(action: onOpenSettings) {
100
+                    SidebarLinkCardView(
101
+                        title: "Open Settings",
102
+                        subtitle: nil,
103
+                        symbol: "gearshape.fill",
104
+                        tint: .indigo
105
+                    )
106
+                    }
107
+                    .buttonStyle(.plain)
108
+                }
109
+
110
+                NavigationLink(destination: bluetoothHelpDestination) {
111
+                    SidebarLinkCardView(
112
+                        title: "Bluetooth",
113
+                        subtitle: nil,
114
+                        symbol: "bolt.horizontal.circle.fill",
115
+                        tint: bluetoothStatusTint
116
+                    )
117
+                }
118
+                .buttonStyle(.plain)
119
+
120
+                NavigationLink(destination: deviceHelpDestination) {
121
+                    SidebarLinkCardView(
122
+                        title: "Device",
123
+                        subtitle: nil,
124
+                        symbol: "questionmark.circle.fill",
125
+                        tint: .orange
126
+                    )
127
+                }
128
+                .buttonStyle(.plain)
129
+            }
130
+        }
131
+        .animation(.easeInOut(duration: 0.22), value: isExpanded)
132
+    }
133
+
134
+    private var sectionTint: Color {
135
+        activeReason?.tint ?? .secondary
136
+    }
137
+
138
+    private var sectionSymbol: String {
139
+        activeReason?.symbol ?? "questionmark.circle.fill"
140
+    }
141
+}
+110 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -0,0 +1,110 @@
1
+//
2
+//  SidebarUSBMetersSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+import CoreBluetooth
8
+
9
+struct SidebarUSBMetersSectionView: View {
10
+    let meters: [AppData.MeterSummary]
11
+    let managerState: CBManagerState
12
+    let hasLiveMeters: Bool
13
+    let scanStartedAt: Date?
14
+    let now: Date
15
+    let noDevicesHelpDelay: TimeInterval
16
+
17
+    var body: some View {
18
+        Section(header: usbSectionHeader) {
19
+            if meters.isEmpty {
20
+                Text(devicesEmptyStateText)
21
+                    .font(.footnote)
22
+                    .foregroundColor(.secondary)
23
+                    .frame(maxWidth: .infinity, alignment: .leading)
24
+                    .padding(18)
25
+                    .meterCard(
26
+                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
27
+                        fillOpacity: 0.14,
28
+                        strokeOpacity: 0.20
29
+                    )
30
+            } else {
31
+                ForEach(meters) { meterSummary in
32
+                    if let meter = meterSummary.meter {
33
+                        NavigationLink(destination: MeterView().environmentObject(meter)) {
34
+                            MeterRowView()
35
+                                .environmentObject(meter)
36
+                        }
37
+                        .buttonStyle(.plain)
38
+                    } else {
39
+                        NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
40
+                            MeterCardView(meterSummary: meterSummary)
41
+                        }
42
+                        .buttonStyle(.plain)
43
+                    }
44
+                }
45
+            }
46
+        }
47
+    }
48
+
49
+    private var isWaitingForFirstDiscovery: Bool {
50
+        guard managerState == .poweredOn else {
51
+            return false
52
+        }
53
+        guard hasLiveMeters == false else {
54
+            return false
55
+        }
56
+        guard let scanStartedAt else {
57
+            return false
58
+        }
59
+        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
60
+    }
61
+
62
+    private var devicesEmptyStateText: String {
63
+        if isWaitingForFirstDiscovery {
64
+            return "Scanning for nearby supported meters..."
65
+        }
66
+        return "No meters yet. Nearby supported meters will appear here and remain available after they disappear."
67
+    }
68
+
69
+    private var usbSectionHeader: some View {
70
+        HStack(alignment: .firstTextBaseline) {
71
+            VStack(alignment: .leading, spacing: 2) {
72
+                Text("USB & Known Meters")
73
+                    .font(.headline)
74
+                if meters.isEmpty == false {
75
+                    Text(sectionSubtitleText)
76
+                        .font(.caption)
77
+                        .foregroundColor(.secondary)
78
+                        .lineLimit(1)
79
+                }
80
+            }
81
+            Spacer()
82
+            Text("\(meters.count)")
83
+                .font(.caption.weight(.bold))
84
+                .padding(.horizontal, 10)
85
+                .padding(.vertical, 6)
86
+                .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
87
+        }
88
+    }
89
+
90
+    private var sectionSubtitleText: String {
91
+        switch (liveMeterCount, offlineMeterCount) {
92
+        case let (live, offline) where live > 0 && offline > 0:
93
+            return "\(live) live • \(offline) stored"
94
+        case let (live, _) where live > 0:
95
+            return "\(live) live meter\(live == 1 ? "" : "s")"
96
+        case let (_, offline) where offline > 0:
97
+            return "\(offline) known meter\(offline == 1 ? "" : "s")"
98
+        default:
99
+            return ""
100
+        }
101
+    }
102
+
103
+    private var liveMeterCount: Int {
104
+        meters.filter { $0.meter != nil }.count
105
+    }
106
+
107
+    private var offlineMeterCount: Int {
108
+        max(0, meters.count - liveMeterCount)
109
+    }
110
+}
+53 -0
USB Meter/Views/Sidebar/SidebarList/SidebarListView.swift
@@ -0,0 +1,53 @@
1
+//
2
+//  SidebarListView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarListView<USBMetersSection: View, HelpSection: View, DebugSection: View>: View {
9
+    let backgroundTint: Color
10
+    let usbMetersSection: USBMetersSection
11
+    let helpSection: HelpSection
12
+    let debugSection: DebugSection
13
+
14
+    init(
15
+        backgroundTint: Color,
16
+        @ViewBuilder usbMetersSection: () -> USBMetersSection,
17
+        @ViewBuilder helpSection: () -> HelpSection,
18
+        @ViewBuilder debugSection: () -> DebugSection
19
+    ) {
20
+        self.backgroundTint = backgroundTint
21
+        self.usbMetersSection = usbMetersSection()
22
+        self.helpSection = helpSection()
23
+        self.debugSection = debugSection()
24
+    }
25
+
26
+    var body: some View {
27
+        if #available(iOS 16.0, *) {
28
+            listBody.scrollContentBackground(.hidden)
29
+        } else {
30
+            listBody
31
+        }
32
+    }
33
+
34
+    private var listBody: some View {
35
+        List {
36
+            usbMetersSection
37
+            helpSection
38
+            debugSection
39
+        }
40
+        .listStyle(SidebarListStyle())
41
+        .background(
42
+            LinearGradient(
43
+                colors: [
44
+                    backgroundTint.opacity(0.18),
45
+                    Color.clear
46
+                ],
47
+                startPoint: .topLeading,
48
+                endPoint: .bottomTrailing
49
+            )
50
+            .ignoresSafeArea()
51
+        )
52
+    }
53
+}
+129 -0
USB Meter/Views/Sidebar/SidebarView.swift
@@ -0,0 +1,129 @@
1
+//
2
+//  SidebarView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+import Combine
8
+
9
+struct SidebarView: View {
10
+    @EnvironmentObject private var appData: AppData
11
+    @State private var isHelpExpanded = false
12
+    @State private var dismissedAutoHelpReason: SidebarHelpReason?
13
+    @State private var now = Date()
14
+    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
15
+    private let noDevicesHelpDelay: TimeInterval = 12
16
+
17
+    var body: some View {
18
+        SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
19
+            usbMetersSection
20
+        } helpSection: {
21
+            helpSection
22
+        } debugSection: {
23
+            debugSection
24
+        }
25
+        .onAppear {
26
+            appData.bluetoothManager.start()
27
+            now = Date()
28
+        }
29
+        .onReceive(helpRefreshTimer) { currentDate in
30
+            now = currentDate
31
+        }
32
+        .onChange(of: activeHelpAutoReason) { newReason in
33
+            if newReason == nil {
34
+                dismissedAutoHelpReason = nil
35
+            }
36
+        }
37
+    }
38
+
39
+    private var usbMetersSection: some View {
40
+        SidebarUSBMetersSectionView(
41
+            meters: appData.meterSummaries,
42
+            managerState: appData.bluetoothManager.managerState,
43
+            hasLiveMeters: appData.meters.isEmpty == false,
44
+            scanStartedAt: appData.bluetoothManager.scanStartedAt,
45
+            now: now,
46
+            noDevicesHelpDelay: noDevicesHelpDelay
47
+        )
48
+    }
49
+
50
+    private var helpSection: some View {
51
+        SidebarHelpSectionView(
52
+            activeReason: activeHelpAutoReason,
53
+            isExpanded: helpIsExpanded,
54
+            bluetoothStatusTint: appData.bluetoothManager.managerState.color,
55
+            bluetoothStatusText: bluetoothStatusText,
56
+            cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
57
+            cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
58
+            onToggle: toggleHelpSection,
59
+            onOpenSettings: openSettings
60
+        ) {
61
+            appData.bluetoothManager.managerState.helpView
62
+        } deviceHelpDestination: {
63
+            DeviceHelpView()
64
+        }
65
+    }
66
+
67
+    private var debugSection: some View {
68
+        SidebarDebugSectionView()
69
+    }
70
+
71
+    private var bluetoothStatusText: String {
72
+        switch appData.bluetoothManager.managerState {
73
+        case .poweredOff:
74
+            return "Off"
75
+        case .poweredOn:
76
+            return "On"
77
+        case .resetting:
78
+            return "Resetting"
79
+        case .unauthorized:
80
+            return "Unauthorized"
81
+        case .unknown:
82
+            return "Unknown"
83
+        case .unsupported:
84
+            return "Unsupported"
85
+        @unknown default:
86
+            return "Other"
87
+        }
88
+    }
89
+
90
+    private var helpIsExpanded: Bool {
91
+        isHelpExpanded || shouldAutoExpandHelp
92
+    }
93
+
94
+    private var shouldAutoExpandHelp: Bool {
95
+        guard let activeHelpAutoReason else {
96
+            return false
97
+        }
98
+        return dismissedAutoHelpReason != activeHelpAutoReason
99
+    }
100
+
101
+    private var activeHelpAutoReason: SidebarHelpReason? {
102
+        SidebarAutoHelpResolver.activeReason(
103
+            managerState: appData.bluetoothManager.managerState,
104
+            cloudAvailability: appData.cloudAvailability,
105
+            hasLiveMeters: appData.meters.isEmpty == false,
106
+            scanStartedAt: appData.bluetoothManager.scanStartedAt,
107
+            now: now,
108
+            noDevicesHelpDelay: noDevicesHelpDelay
109
+        )
110
+    }
111
+
112
+    private func toggleHelpSection() {
113
+        withAnimation(.easeInOut(duration: 0.22)) {
114
+            if shouldAutoExpandHelp {
115
+                dismissedAutoHelpReason = activeHelpAutoReason
116
+                isHelpExpanded = false
117
+            } else {
118
+                isHelpExpanded.toggle()
119
+            }
120
+        }
121
+    }
122
+
123
+    private func openSettings() {
124
+        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
125
+            return
126
+        }
127
+        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
128
+    }
129
+}