@@ -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. |
|
@@ -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 |
|
@@ -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 |
} |
@@ -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 |
- |
|
@@ -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 |
} |
@@ -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 |
} |
@@ -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
|
@@ -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> |
|
@@ -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> |
|
@@ -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) |
@@ -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 |
} |
@@ -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 |
@@ -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 |
+} |
|
@@ -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 |
- |
|
@@ -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 |
-} |
|
@@ -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> |
@@ -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,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. |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
-} |
|
@@ -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 |
-} |
|
@@ -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 |
-} |
|
@@ -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 {
|
@@ -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 |
} |
@@ -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 |
+} |
|
@@ -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,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. |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
} |
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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) |
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|