Showing 13 changed files with 546 additions and 174 deletions
+13 -20
USB Meter.xcodeproj/project.pbxproj
@@ -11,6 +11,7 @@
11 11
 		4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsView.swift */; };
12 12
 		430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
13 13
 		4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
14
+		E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.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 */; };
@@ -18,7 +19,6 @@
18 19
 		43554B2F24443939004E66F5 /* MeasurementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementsView.swift */; };
19 20
 		43554B32244449B5004E66F5 /* MeasurementPointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementPointView.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 23
 		4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */; };
24 24
 		437D47D12415F91B00B7768E /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* LiveView.swift */; };
@@ -29,6 +29,7 @@
29 29
 		4383B460240EB2D000DAAEBF /* Meter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B45F240EB2D000DAAEBF /* Meter.swift */; };
30 30
 		4383B462240EB5E400DAAEBF /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B461240EB5E400DAAEBF /* AppData.swift */; };
31 31
 		4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B464240EB6B200DAAEBF /* UserDefault.swift */; };
32
+		3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */; };
32 33
 		4383B468240F845500DAAEBF /* MacAdress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B467240F845500DAAEBF /* MacAdress.swift */; };
33 34
 		4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B469240FE4A600DAAEBF /* MeterView.swift */; };
34 35
 		438695892463F062008855A9 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438695882463F062008855A9 /* Measurements.swift */; };
@@ -42,7 +43,6 @@
42 43
 		439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439D996424234B98008DE3AA /* BluetoothRadio.swift */; };
43 44
 		43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF65F240BF3EB00255B8B /* AppDelegate.swift */; };
44 45
 		43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF661240BF3EB00255B8B /* SceneDelegate.swift */; };
45
-		43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */; };
46 46
 		43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF666240BF3EB00255B8B /* ContentView.swift */; };
47 47
 		43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF668240BF3ED00255B8B /* Assets.xcassets */; };
48 48
 		43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */; };
@@ -90,13 +90,13 @@
90 90
 		4308CF872417770D0002E80B /* DataGroupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsView.swift; sourceTree = "<group>"; };
91 91
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
92 92
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
93
+		56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterMappingDebugView.swift; sourceTree = "<group>"; };
93 94
 		4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
94 95
 		432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
95 96
 		4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
96 97
 		43554B2E24443939004E66F5 /* MeasurementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementsView.swift; sourceTree = "<group>"; };
97 98
 		43554B31244449B5004E66F5 /* MeasurementPointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementPointView.swift; sourceTree = "<group>"; };
98 99
 		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 100
 		4360A34C241CBB3800B464F9 /* RSSIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSIView.swift; sourceTree = "<group>"; };
101 101
 		4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsView.swift; sourceTree = "<group>"; };
102 102
 		437D47D02415F91B00B7768E /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
@@ -107,6 +107,7 @@
107 107
 		4383B45F240EB2D000DAAEBF /* Meter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meter.swift; sourceTree = "<group>"; };
108 108
 		4383B461240EB5E400DAAEBF /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
109 109
 		4383B464240EB6B200DAAEBF /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
110
+		7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameStore.swift; sourceTree = "<group>"; };
110 111
 		4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
111 112
 		4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
112 113
 		438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
@@ -121,7 +122,6 @@
121 122
 		43CBF65C240BF3EB00255B8B /* USB Meter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "USB Meter.app"; sourceTree = BUILT_PRODUCTS_DIR; };
122 123
 		43CBF65F240BF3EB00255B8B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
123 124
 		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 125
 		43CBF666240BF3EB00255B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
126 126
 		43CBF668240BF3ED00255B8B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
127 127
 		43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
@@ -295,7 +295,6 @@
295 295
 			isa = PBXGroup;
296 296
 			children = (
297 297
 				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
298
-				43567FE82443AD7C00000282 /* ICloudDefault.swift */,
299 298
 			);
300 299
 			path = Templates;
301 300
 			sourceTree = "<group>";
@@ -357,6 +356,7 @@
357 356
 			isa = PBXGroup;
358 357
 			children = (
359 358
 				4383B461240EB5E400DAAEBF /* AppData.swift */,
359
+				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
360 360
 				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
361 361
 				4383B45F240EB2D000DAAEBF /* Meter.swift */,
362 362
 				43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */,
@@ -364,7 +364,6 @@
364 364
 				4386958E2F6A4E3E008855A9 /* MeterCapabilities.swift */,
365 365
 				4386958A2F6A1001008855A9 /* UMProtocol.swift */,
366 366
 				4386958C2F6A1002008855A9 /* TC66Protocol.swift */,
367
-				43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */,
368 367
 				438695882463F062008855A9 /* Measurements.swift */,
369 368
 				432EA6432445A559006FC905 /* ChartContext.swift */,
370 369
 			);
@@ -375,6 +374,7 @@
375 374
 			isa = PBXGroup;
376 375
 			children = (
377 376
 				43CBF666240BF3EB00255B8B /* ContentView.swift */,
377
+				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
378 378
 				4327461A24619CED0009BE4B /* MeterRowView.swift */,
379 379
 				437D47CF2415F8CF00B7768E /* Meter */,
380 380
 				4311E639241384960080EA59 /* DeviceHelpView.swift */,
@@ -435,6 +435,11 @@
435 435
 				TargetAttributes = {
436 436
 					43CBF65B240BF3EB00255B8B = {
437 437
 						CreatedOnToolsVersion = 11.3.1;
438
+						SystemCapabilities = {
439
+							com.apple.iCloud = {
440
+								enabled = 1;
441
+							};
442
+						};
438 443
 					};
439 444
 				};
440 445
 			};
@@ -480,15 +485,14 @@
480 485
 				43874C852415611200525397 /* Double.swift in Sources */,
481 486
 				437D47D72415FDF300B7768E /* ControlView.swift in Sources */,
482 487
 				4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */,
483
-				43567FE92443AD7C00000282 /* ICloudDefault.swift in Sources */,
484 488
 				4383B468240F845500DAAEBF /* MacAdress.swift in Sources */,
485 489
 				43CBF681240D153000255B8B /* CBManagerState.swift in Sources */,
486 490
 				4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */,
487 491
 				4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */,
488 492
 				437D47D12415F91B00B7768E /* LiveView.swift in Sources */,
489
-				43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */,
490 493
 				4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */,
491 494
 				4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */,
495
+				3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */,
492 496
 				43CBF677240C043E00255B8B /* BluetoothManager.swift in Sources */,
493 497
 				43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */,
494 498
 				438B9555246D2D7500E61AE7 /* Path.swift in Sources */,
@@ -514,6 +518,7 @@
514 518
 				430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */,
515 519
 				43554B3424444B0E004E66F5 /* Date.swift in Sources */,
516 520
 				4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */,
521
+				E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */,
517 522
 				439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */,
518 523
 				438695892463F062008855A9 /* Measurements.swift in Sources */,
519 524
 				4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */,
@@ -747,18 +752,6 @@
747 752
 		};
748 753
 /* End XCSwiftPackageProductDependency section */
749 754
 
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 755
 	};
763 756
 	rootObject = 43CBF654240BF3EB00255B8B /* Project object */;
764 757
 }
+8 -49
USB Meter/AppDelegate.swift
@@ -7,7 +7,6 @@
7 7
 //
8 8
 
9 9
 import UIKit
10
-import CoreData
11 10
 
12 11
 //let btSerial = BluetoothSerial(delegate: BSD())
13 12
 let appData = AppData()
@@ -32,10 +31,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
32 31
 
33 32
 
34 33
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
35
-        // Override point for customization after application launch.
34
+        logRuntimeICloudDiagnostics()
36 35
         return true
37 36
     }
38 37
 
38
+    private func logRuntimeICloudDiagnostics() {
39
+        #if DEBUG
40
+        let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil
41
+        track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
42
+        #endif
43
+    }
44
+
39 45
     // MARK: UISceneSession Lifecycle
40 46
 
41 47
     func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
@@ -49,51 +55,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
49 55
         // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
50 56
         // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
51 57
     }
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 58
 }
99
-
+61 -40
USB Meter/Model/AppData.swift
@@ -11,56 +11,52 @@ import Combine
11 11
 import CoreBluetooth
12 12
 
13 13
 final class AppData : ObservableObject {
14
-    private var icloudGefaultsNotification: AnyCancellable?
15 14
     private var bluetoothManagerNotification: AnyCancellable?
15
+    private var meterStoreObserver: AnyCancellable?
16
+    private var meterStoreCloudObserver: AnyCancellable?
17
+    private let meterStore = MeterNameStore.shared
16 18
 
17 19
     init() {
18
-        icloudGefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: test)
19 20
         bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
20 21
             self?.scheduleObjectWillChange()
21 22
         }
22
-        //NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: { notification in
23
-        
23
+        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
24
+            .receive(on: DispatchQueue.main)
25
+            .sink { [weak self] _ in
26
+                self?.refreshMeterMetadata()
27
+            }
28
+        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
29
+            .receive(on: DispatchQueue.main)
30
+            .sink { [weak self] _ in
31
+                self?.scheduleObjectWillChange()
32
+            }
24 33
     }
25
-    
34
+
26 35
     let bluetoothManager = BluetoothManager()
27
-    
36
+
28 37
     @Published var enableRecordFeature: Bool = true
29
-    
38
+
30 39
     @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
-                }
59
-            }
60
-            if somethingChanged {
61
-                scheduleObjectWillChange()
62
-            }
63
-        }
40
+
41
+    var cloudAvailability: MeterNameStore.CloudAvailability {
42
+        meterStore.currentCloudAvailability
43
+    }
44
+
45
+    func meterName(for macAddress: String) -> String? {
46
+        meterStore.name(for: macAddress)
47
+    }
48
+
49
+    func setMeterName(_ name: String, for macAddress: String) {
50
+        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
51
+    }
52
+
53
+    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
54
+        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
55
+        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
56
+    }
57
+
58
+    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
59
+        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
64 60
     }
65 61
 
66 62
     private func scheduleObjectWillChange() {
@@ -68,4 +64,29 @@ final class AppData : ObservableObject {
68 64
             self?.objectWillChange.send()
69 65
         }
70 66
     }
67
+
68
+    private func refreshMeterMetadata() {
69
+        DispatchQueue.main.async { [weak self] in
70
+            guard let self else { return }
71
+            var didUpdateAnyMeter = false
72
+            for meter in self.meters.values {
73
+                let mac = meter.btSerial.macAddress.description
74
+                let displayName = self.meterName(for: mac) ?? mac
75
+                if meter.name != displayName {
76
+                    meter.updateNameFromStore(displayName)
77
+                    didUpdateAnyMeter = true
78
+                }
79
+
80
+                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
81
+                meter.reloadTemperatureUnitPreference()
82
+                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
83
+                    didUpdateAnyMeter = true
84
+                }
85
+            }
86
+
87
+            if didUpdateAnyMeter {
88
+                self.scheduleObjectWillChange()
89
+            }
90
+        }
91
+    }
71 92
 }
+4 -0
USB Meter/Model/BluetoothManager.swift
@@ -71,6 +71,10 @@ class BluetoothManager : NSObject, ObservableObject {
71 71
         } else if let meter = appData.meters[peripheral.identifier] {
72 72
             meter.lastSeen = Date()
73 73
             meter.btSerial.updateRSSI(RSSI.intValue)
74
+            let macAddress = meter.btSerial.macAddress.description
75
+            if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
76
+                meter.updateNameFromStore(syncedName)
77
+            }
74 78
             if peripheral.delegate == nil {
75 79
                 peripheral.delegate = meter.btSerial
76 80
             }
+0 -8
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -1,8 +0,0 @@
1
-<?xml version="1.0" encoding="UTF-8"?>
2
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
-<plist version="1.0">
4
-<dict>
5
-	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter.xcdatamodel</string>
7
-</dict>
8
-</plist>
+0 -7
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter.xcdatamodel/contents
@@ -1,7 +0,0 @@
1
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19E287" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
3
-    <entity name="Entity" representedClassName="Entity" syncable="YES" codeGenerationType="class"/>
4
-    <elements>
5
-        <element name="Entity" positionX="-63" positionY="-18" width="128" height="43"/>
6
-    </elements>
7
-</model>
+16 -8
USB Meter/Model/Meter.swift
@@ -170,9 +170,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
170 170
     var model: Model
171 171
     var modelString: String
172 172
     
173
-    var name: String {
173
+    private var isSyncingNameFromStore = false
174
+
175
+    @Published var name: String {
174 176
         didSet {
175
-            appData.meterNames[btSerial.macAddress.description] = name
177
+            guard !isSyncingNameFromStore else { return }
178
+            guard oldValue != name else { return }
179
+            appData.setMeterName(name, for: btSerial.macAddress.description)
176 180
         }
177 181
     }
178 182
     
@@ -441,9 +445,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
441 445
         didSet {
442 446
             guard supportsManualTemperatureUnitSelection else { return }
443 447
             guard oldValue != tc66TemperatureUnitPreference else { return }
444
-            var settings = appData.tc66TemperatureUnits
445
-            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
446
-            appData.tc66TemperatureUnits = settings
448
+            appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description)
447 449
         }
448 450
     }
449 451
 
@@ -544,7 +546,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
544 546
         modelString = serialPort.peripheral.name!
545 547
         self.model = model
546 548
         btSerial = serialPort
547
-        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
549
+        name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description
548 550
         super.init()
549 551
         btSerial.delegate = self
550 552
         reloadTemperatureUnitPreference()
@@ -556,13 +558,19 @@ class Meter : NSObject, ObservableObject, Identifiable {
556 558
 
557 559
     func reloadTemperatureUnitPreference() {
558 560
         guard supportsManualTemperatureUnitSelection else { return }
559
-        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
560
-        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
561
+        let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description)
561 562
         if tc66TemperatureUnitPreference != persistedPreference {
562 563
             tc66TemperatureUnitPreference = persistedPreference
563 564
         }
564 565
     }
565 566
 
567
+    func updateNameFromStore(_ newName: String) {
568
+        guard newName != name else { return }
569
+        isSyncingNameFromStore = true
570
+        name = newName
571
+        isSyncingNameFromStore = false
572
+    }
573
+
566 574
     private func cancelPendingDataDumpRequest(reason: String) {
567 575
         guard let pendingDataDumpWorkItem else { return }
568 576
         track("\(name) - Cancel scheduled data request (\(reason))")
+325 -0
USB Meter/Model/MeterNameStore.swift
@@ -0,0 +1,325 @@
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
+
15
+        var id: String {
16
+            macAddress
17
+        }
18
+    }
19
+
20
+    enum CloudAvailability: Equatable {
21
+        case unknown
22
+        case available
23
+        case noAccount
24
+        case error(String)
25
+
26
+        var helpTitle: String {
27
+            switch self {
28
+            case .unknown:
29
+                return "Cloud Sync Status Unknown"
30
+            case .available:
31
+                return "Cloud Sync Ready"
32
+            case .noAccount:
33
+                return "Enable iCloud Drive"
34
+            case .error:
35
+                return "Cloud Sync Error"
36
+            }
37
+        }
38
+
39
+        var helpMessage: String {
40
+            switch self {
41
+            case .unknown:
42
+                return "The app is still checking whether iCloud sync is available on this device."
43
+            case .available:
44
+                return "iCloud sync is available for meter names and TC66 temperature preferences."
45
+            case .noAccount:
46
+                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."
47
+            case .error(let description):
48
+                return "The app keeps local values, but iCloud sync reported an error: \(description)"
49
+            }
50
+        }
51
+    }
52
+
53
+    static let shared = MeterNameStore()
54
+
55
+    private enum Keys {
56
+        static let localMeterNames = "MeterNameStore.localMeterNames"
57
+        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
58
+        static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
59
+        static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
60
+    }
61
+
62
+    private let defaults = UserDefaults.standard
63
+    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
64
+    private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
65
+    private var cloudAvailability: CloudAvailability = .unknown
66
+    private var ubiquitousObserver: NSObjectProtocol?
67
+    private var ubiquityIdentityObserver: NSObjectProtocol?
68
+
69
+    private init() {
70
+        ubiquitousObserver = NotificationCenter.default.addObserver(
71
+            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
72
+            object: ubiquitousStore,
73
+            queue: nil
74
+        ) { [weak self] notification in
75
+            self?.handleUbiquitousStoreChange(notification)
76
+        }
77
+
78
+        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
79
+            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
80
+            object: nil,
81
+            queue: nil
82
+        ) { [weak self] _ in
83
+            self?.refreshCloudAvailability(reason: "identity-changed")
84
+            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
85
+        }
86
+
87
+        refreshCloudAvailability(reason: "startup")
88
+        ubiquitousStore.synchronize()
89
+        syncLocalValuesToCloudIfPossible(reason: "startup")
90
+    }
91
+
92
+    var currentCloudAvailability: CloudAvailability {
93
+        workQueue.sync {
94
+            cloudAvailability
95
+        }
96
+    }
97
+
98
+    func name(for macAddress: String) -> String? {
99
+        let normalizedMAC = normalizedMACAddress(macAddress)
100
+        guard !normalizedMAC.isEmpty else { return nil }
101
+        return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC]
102
+    }
103
+
104
+    func temperatureUnitRawValue(for macAddress: String) -> String? {
105
+        let normalizedMAC = normalizedMACAddress(macAddress)
106
+        guard !normalizedMAC.isEmpty else { return nil }
107
+        return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
108
+    }
109
+
110
+    func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
111
+        let normalizedMAC = normalizedMACAddress(macAddress)
112
+        guard !normalizedMAC.isEmpty else {
113
+            track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
114
+            return
115
+        }
116
+
117
+        var didChange = false
118
+
119
+        if let name {
120
+            didChange = updateDictionaryValue(
121
+                for: normalizedMAC,
122
+                value: normalizedName(name),
123
+                localKey: Keys.localMeterNames,
124
+                cloudKey: Keys.cloudMeterNames
125
+            ) || didChange
126
+        }
127
+
128
+        if let temperatureUnitRawValue {
129
+            didChange = updateDictionaryValue(
130
+                for: normalizedMAC,
131
+                value: normalizedTemperatureUnit(temperatureUnitRawValue),
132
+                localKey: Keys.localTemperatureUnits,
133
+                cloudKey: Keys.cloudTemperatureUnits
134
+            ) || didChange
135
+        }
136
+
137
+        if didChange {
138
+            notifyChange()
139
+        }
140
+    }
141
+
142
+    func allRecords() -> [Record] {
143
+        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
144
+        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
145
+        let macAddresses = Set(names.keys).union(temperatureUnits.keys)
146
+
147
+        return macAddresses.sorted().map { macAddress in
148
+            Record(
149
+                macAddress: macAddress,
150
+                customName: names[macAddress],
151
+                temperatureUnit: temperatureUnits[macAddress]
152
+            )
153
+        }
154
+    }
155
+
156
+    private func normalizedMACAddress(_ macAddress: String) -> String {
157
+        macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
158
+    }
159
+
160
+    private func normalizedName(_ name: String?) -> String? {
161
+        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
162
+              !trimmed.isEmpty else {
163
+            return nil
164
+        }
165
+        return trimmed
166
+    }
167
+
168
+    private func normalizedTemperatureUnit(_ value: String?) -> String? {
169
+        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
170
+              !trimmed.isEmpty else {
171
+            return nil
172
+        }
173
+        return trimmed
174
+    }
175
+
176
+    private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
177
+        (store.object(forKey: key) as? [String: String]) ?? [:]
178
+    }
179
+
180
+    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
181
+        let localValues = dictionary(for: localKey, store: defaults)
182
+        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
183
+        return localValues.merging(cloudValues) { _, cloudValue in
184
+            cloudValue
185
+        }
186
+    }
187
+
188
+    @discardableResult
189
+    private func updateDictionaryValue(
190
+        for macAddress: String,
191
+        value: String?,
192
+        localKey: String,
193
+        cloudKey: String
194
+    ) -> Bool {
195
+        var localValues = dictionary(for: localKey, store: defaults)
196
+        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
197
+        if didChangeLocal {
198
+            defaults.set(localValues, forKey: localKey)
199
+        }
200
+
201
+        var didChangeCloud = false
202
+        if isICloudDriveAvailable {
203
+            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
204
+            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
205
+            if didChangeCloud {
206
+                ubiquitousStore.set(cloudValues, forKey: cloudKey)
207
+                ubiquitousStore.synchronize()
208
+            }
209
+        }
210
+
211
+        return didChangeLocal || didChangeCloud
212
+    }
213
+
214
+    @discardableResult
215
+    private func setDictionaryValue(
216
+        _ dictionary: inout [String: String],
217
+        for macAddress: String,
218
+        value: String?
219
+    ) -> Bool {
220
+        let currentValue = dictionary[macAddress]
221
+        guard currentValue != value else { return false }
222
+        if let value {
223
+            dictionary[macAddress] = value
224
+        } else {
225
+            dictionary.removeValue(forKey: macAddress)
226
+        }
227
+        return true
228
+    }
229
+
230
+    private var isICloudDriveAvailable: Bool {
231
+        FileManager.default.ubiquityIdentityToken != nil
232
+    }
233
+
234
+    private func refreshCloudAvailability(reason: String) {
235
+        let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
236
+
237
+        var shouldNotify = false
238
+        workQueue.sync {
239
+            guard cloudAvailability != newAvailability else { return }
240
+            cloudAvailability = newAvailability
241
+            shouldNotify = true
242
+        }
243
+
244
+        guard shouldNotify else { return }
245
+        track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
246
+        DispatchQueue.main.async {
247
+            NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
248
+        }
249
+    }
250
+
251
+    private func handleUbiquitousStoreChange(_ notification: Notification) {
252
+        refreshCloudAvailability(reason: "ubiquitous-store-change")
253
+        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
254
+            track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
255
+        }
256
+        notifyChange()
257
+    }
258
+
259
+    private func syncLocalValuesToCloudIfPossible(reason: String) {
260
+        guard isICloudDriveAvailable else {
261
+            refreshCloudAvailability(reason: reason)
262
+            return
263
+        }
264
+
265
+        let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
266
+        let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
267
+
268
+        var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
269
+        var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
270
+
271
+        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
272
+            cloudValue
273
+        }
274
+        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
275
+            cloudValue
276
+        }
277
+
278
+        var didChange = false
279
+        if cloudNames != mergedNames {
280
+            cloudNames = mergedNames
281
+            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
282
+            didChange = true
283
+        }
284
+        if cloudTemperatureUnits != mergedTemperatureUnits {
285
+            cloudTemperatureUnits = mergedTemperatureUnits
286
+            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
287
+            didChange = true
288
+        }
289
+
290
+        refreshCloudAvailability(reason: reason)
291
+
292
+        if didChange {
293
+            ubiquitousStore.synchronize()
294
+            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
295
+            notifyChange()
296
+        }
297
+    }
298
+
299
+    private func notifyChange() {
300
+        DispatchQueue.main.async {
301
+            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
302
+        }
303
+    }
304
+
305
+    deinit {
306
+        if let observer = ubiquitousObserver {
307
+            NotificationCenter.default.removeObserver(observer)
308
+        }
309
+        if let observer = ubiquityIdentityObserver {
310
+            NotificationCenter.default.removeObserver(observer)
311
+        }
312
+    }
313
+}
314
+
315
+private protocol KeyValueReading {
316
+    func object(forKey defaultName: String) -> Any?
317
+}
318
+
319
+extension UserDefaults: KeyValueReading {}
320
+extension NSUbiquitousKeyValueStore: KeyValueReading {}
321
+
322
+extension Notification.Name {
323
+    static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
324
+    static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
325
+}
+0 -9
USB Meter/SceneDelegate.swift
@@ -20,13 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
20 20
         // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
21 21
         // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
22 22
         
23
-        // Get the managed object context from the shared persistent container.
24
-        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
25
-        
26
-        // Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
27
-        // Add `@Environment(\.managedObjectContext)` in the views that will need the context.
28 23
         let contentView = ContentView()
29
-            .environment(\.managedObjectContext, context)
30 24
             .environmentObject(appData)        
31 25
         
32 26
         // Use a UIHostingController as window root view controller.
@@ -65,10 +59,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
65 59
         // Use this method to save data, release shared resources, and store enough scene-specific state information
66 60
         // to restore the scene back to its current state.
67 61
         
68
-        // Save changes in the application's managed object context when the application transitions to the background.
69
-        (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
70 62
     }
71 63
     
72 64
     
73 65
 }
74
-
+0 -29
USB Meter/Templates/ICloudDefault.swift
@@ -1,29 +0,0 @@
1
-//
2
-//  ICloudDefault.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 12/04/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import Foundation
10
-// https://github.com/lobodpav/Xcode11.4Issues/blob/master/Sources/Xcode11.4Test/CloudListener.swift
11
-// https://medium.com/@craiggrummitt/boss-level-property-wrappers-and-user-defaults-6a28c7527cf
12
-@propertyWrapper struct ICloudDefault<T> {
13
-  let key: String
14
-  let defaultValue: T
15
- 
16
-  var wrappedValue: T {
17
-    get {
18
-        return NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
19
-    }
20
-    set {
21
-        NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
22
-        /* MARK: Sincronizarea forțată
23
-            Face ca sincronizarea intre dispozitive mai repidă dar există o limita de update-uri catre iloud
24
-        */
25
-        NSUbiquitousKeyValueStore.default.synchronize()
26
-        track("Pushed into iCloud value: '\(newValue)' for key: '\(key)'")
27
-    }
28
-  }
29
-}
+0 -2
USB Meter/USB Meter.entitlements
@@ -2,8 +2,6 @@
2 2
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 3
 <plist version="1.0">
4 4
 <dict>
5
-	<key>com.apple.developer.icloud-container-identifiers</key>
6
-	<array/>
7 5
 	<key>com.apple.developer.ubiquity-kvstore-identifier</key>
8 6
 	<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
9 7
 </dict>
+59 -2
USB Meter/Views/ContentView.swift
@@ -14,12 +14,15 @@ import Combine
14 14
 struct ContentView: View {
15 15
     private enum HelpAutoReason: String {
16 16
         case bluetoothPermission
17
+        case cloudSyncUnavailable
17 18
         case noDevicesDetected
18 19
 
19 20
         var tint: Color {
20 21
             switch self {
21 22
             case .bluetoothPermission:
22 23
                 return .orange
24
+            case .cloudSyncUnavailable:
25
+                return .indigo
23 26
             case .noDevicesDetected:
24 27
                 return .yellow
25 28
             }
@@ -29,6 +32,8 @@ struct ContentView: View {
29 32
             switch self {
30 33
             case .bluetoothPermission:
31 34
                 return "bolt.horizontal.circle.fill"
35
+            case .cloudSyncUnavailable:
36
+                return "icloud.slash.fill"
32 37
             case .noDevicesDetected:
33 38
                 return "magnifyingglass.circle.fill"
34 39
             }
@@ -38,25 +43,28 @@ struct ContentView: View {
38 43
             switch self {
39 44
             case .bluetoothPermission:
40 45
                 return "Required"
46
+            case .cloudSyncUnavailable:
47
+                return "Sync Off"
41 48
             case .noDevicesDetected:
42 49
                 return "Suggested"
43 50
             }
44 51
         }
45 52
     }
46
-    
53
+
47 54
     @EnvironmentObject private var appData: AppData
48 55
     @State private var isHelpExpanded = false
49 56
     @State private var dismissedAutoHelpReason: HelpAutoReason?
50 57
     @State private var now = Date()
51 58
     private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
52 59
     private let noDevicesHelpDelay: TimeInterval = 12
53
-    
60
+
54 61
     var body: some View {
55 62
         NavigationView {
56 63
             ScrollView {
57 64
                 VStack(alignment: .leading, spacing: 18) {
58 65
                     headerCard
59 66
                     helpSection
67
+                    debugLink
60 68
                     devicesSection
61 69
                 }
62 70
                 .padding()
@@ -159,6 +167,18 @@ struct ContentView: View {
159 167
                     helpNoticeCard(for: activeHelpAutoReason)
160 168
                 }
161 169
 
170
+                if activeHelpAutoReason == .cloudSyncUnavailable {
171
+                    Button(action: openSettings) {
172
+                        sidebarLinkCard(
173
+                            title: "Open Settings",
174
+                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
175
+                            symbol: "gearshape.fill",
176
+                            tint: .indigo
177
+                        )
178
+                    }
179
+                    .buttonStyle(.plain)
180
+                }
181
+
162 182
                 NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
163 183
                     sidebarLinkCard(
164 184
                         title: "Bluetooth",
@@ -219,6 +239,18 @@ struct ContentView: View {
219 239
         }
220 240
     }
221 241
 
242
+    private var debugLink: some View {
243
+        NavigationLink(destination: MeterMappingDebugView()) {
244
+            sidebarLinkCard(
245
+                title: "Meter Mapping Debug",
246
+                subtitle: "Inspect the MAC ↔ name/TC66 table as seen by this device.",
247
+                symbol: "list.bullet.rectangle",
248
+                tint: .purple
249
+            )
250
+        }
251
+        .buttonStyle(.plain)
252
+    }
253
+
222 254
     private var discoveredMeters: [Meter] {
223 255
         Array(appData.meters.values).sorted { lhs, rhs in
224 256
             lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
@@ -259,12 +291,24 @@ struct ContentView: View {
259 291
         if appData.bluetoothManager.managerState == .unauthorized {
260 292
             return .bluetoothPermission
261 293
         }
294
+        if shouldPromptForCloudSync {
295
+            return .cloudSyncUnavailable
296
+        }
262 297
         if hasWaitedLongEnoughForDevices {
263 298
             return .noDevicesDetected
264 299
         }
265 300
         return nil
266 301
     }
267 302
 
303
+    private var shouldPromptForCloudSync: Bool {
304
+        switch appData.cloudAvailability {
305
+        case .noAccount, .error:
306
+            return true
307
+        case .unknown, .available:
308
+            return false
309
+        }
310
+    }
311
+
268 312
     private var hasWaitedLongEnoughForDevices: Bool {
269 313
         guard appData.bluetoothManager.managerState == .poweredOn else {
270 314
             return false
@@ -310,6 +354,8 @@ struct ContentView: View {
310 354
         switch activeHelpAutoReason {
311 355
         case .bluetoothPermission:
312 356
             return "Bluetooth permission is needed before scanning can begin."
357
+        case .cloudSyncUnavailable:
358
+            return appData.cloudAvailability.helpMessage
313 359
         case .noDevicesDetected:
314 360
             return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
315 361
         case nil:
@@ -328,6 +374,13 @@ struct ContentView: View {
328 374
         }
329 375
     }
330 376
 
377
+    private func openSettings() {
378
+        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
379
+            return
380
+        }
381
+        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
382
+    }
383
+
331 384
     private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
332 385
         VStack(alignment: .leading, spacing: 8) {
333 386
             Text(helpNoticeTitle(for: reason))
@@ -345,6 +398,8 @@ struct ContentView: View {
345 398
         switch reason {
346 399
         case .bluetoothPermission:
347 400
             return "Bluetooth access needs attention"
401
+        case .cloudSyncUnavailable:
402
+            return appData.cloudAvailability.helpTitle
348 403
         case .noDevicesDetected:
349 404
             return "No supported meters found yet"
350 405
         }
@@ -354,6 +409,8 @@ struct ContentView: View {
354 409
         switch reason {
355 410
         case .bluetoothPermission:
356 411
             return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
412
+        case .cloudSyncUnavailable:
413
+            return appData.cloudAvailability.helpMessage
357 414
         case .noDevicesDetected:
358 415
             return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
359 416
         }
+60 -0
USB Meter/Views/MeterMappingDebugView.swift
@@ -0,0 +1,60 @@
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(records) { record in
16
+            VStack(alignment: .leading, spacing: 6) {
17
+                Text(record.customName)
18
+                    .font(.headline)
19
+                Text(record.macAddress)
20
+                    .font(.caption.monospaced())
21
+                    .foregroundColor(.secondary)
22
+                HStack {
23
+                    Text("TC66 unit:")
24
+                        .font(.caption.weight(.semibold))
25
+                    Text(record.temperatureUnit)
26
+                        .font(.caption.monospaced())
27
+                        .foregroundColor(.blue)
28
+                }
29
+            }
30
+            .padding(.vertical, 8)
31
+        }
32
+        .listStyle(.insetGrouped)
33
+        .navigationTitle("Meter Name Mapping")
34
+        .onAppear(perform: reload)
35
+        .onReceive(changePublisher) { _ in reload() }
36
+        .toolbar {
37
+            Button("Refresh") {
38
+                reload()
39
+            }
40
+        }
41
+    }
42
+
43
+    private func reload() {
44
+        records = store.allRecords().map { record in
45
+            MeterNameRecord(
46
+                id: record.id,
47
+                macAddress: record.macAddress,
48
+                customName: record.customName ?? "<unnamed>",
49
+                temperatureUnit: record.temperatureUnit ?? "n/a"
50
+            )
51
+        }
52
+    }
53
+}
54
+
55
+private struct MeterNameRecord: Identifiable {
56
+    let id: String
57
+    let macAddress: String
58
+    let customName: String
59
+    let temperatureUnit: String
60
+}