listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit 503449273ae112b384ab4370b3a7ff73cb2ea3e7
parent c9e83662081f7131c3275499bab115b367db35ac
Author: Michael Camilleri <[email protected]>
Date:   Wed,  4 Mar 2026 08:15:22 +0900

Sync list title via iCloud

The iOS version of Listless, the list title is configurable via the
Settings screen (it is 'Items' by default). Previous to this commit, the
data was local to the device. This commit causes the value to be synced
between iOS devices.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 6++++++
AListless/Sync/KeyValueSyncBridge.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/ListlessiOSApp.swift | 2++
MListlessiOS/Views/SettingsView.swift | 4++--
4 files changed, 75 insertions(+), 2 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -15,10 +15,12 @@ 1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; }; 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; }; 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; }; + 19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; }; 1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; }; 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; }; 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */; }; 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */; }; + 2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; }; 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; }; 365FDEE6823D7A114F3FB12A /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; }; 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; @@ -127,6 +129,7 @@ A2EF927F09D67532F44BB80D /* TaskRowMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowMetrics.swift; sourceTree = "<group>"; }; AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; }; B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; }; + BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueSyncBridge.swift; sourceTree = "<group>"; }; BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; }; C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; C611E04943F1D82D6F975592 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; @@ -319,6 +322,7 @@ 944BAE054AAC1B9C4FC954F9 /* .gitkeep */, 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */, 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */, + BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */, C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */, ); path = Sync; @@ -491,6 +495,7 @@ E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */, E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */, 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */, + 2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */, 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */, 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */, 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */, @@ -521,6 +526,7 @@ CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */, 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */, E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */, + 19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */, F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */, 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */, F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */, diff --git a/Listless/Sync/KeyValueSyncBridge.swift b/Listless/Sync/KeyValueSyncBridge.swift @@ -0,0 +1,65 @@ +import Foundation + +final class KeyValueSyncBridge { + private let keys: Set<String> + private var isSyncing = false + + init(keys: Set<String>) { + self.keys = keys + } + + func start() { + let cloud = NSUbiquitousKeyValueStore.default + cloud.synchronize() + + for key in keys { + if let cloudValue = cloud.string(forKey: key) { + isSyncing = true + UserDefaults.standard.set(cloudValue, forKey: key) + isSyncing = false + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(cloudDidChange), + name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: cloud + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(defaultsDidChange), + name: UserDefaults.didChangeNotification, + object: nil + ) + } + + @objc private func cloudDidChange(_ notification: Notification) { + guard !isSyncing else { return } + guard let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { + return + } + let cloud = NSUbiquitousKeyValueStore.default + isSyncing = true + for key in changedKeys where keys.contains(key) { + UserDefaults.standard.set(cloud.string(forKey: key), forKey: key) + } + isSyncing = false + } + + @objc private func defaultsDidChange(_ notification: Notification) { + guard !isSyncing else { return } + let defaults = UserDefaults.standard + let cloud = NSUbiquitousKeyValueStore.default + isSyncing = true + for key in keys { + let localValue = defaults.string(forKey: key) + let cloudValue = cloud.string(forKey: key) + if localValue != cloudValue { + cloud.set(localValue, forKey: key) + } + } + isSyncing = false + } +} diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift @@ -4,10 +4,12 @@ import SwiftUI struct ListlessiOSApp: App { @AppStorage("appearanceMode") private var appearanceMode = 0 private let persistenceController: PersistenceController + private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["headingText"]) init() { let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING") persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared + keyValueSyncBridge.start() } var body: some Scene { diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift @@ -9,8 +9,8 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - Section("Heading") { - TextField("Heading", text: $headingText) + Section("List Title") { + TextField("List Title", text: $headingText) } Section("Appearance") {