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

...
Not able to merge. These branches can't be automatically merged.
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
+}