← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit 30ff3e13e48384763e4b4e1d3ac5579ee0191c9c
parent 15bb996563bf53846b97608b473ba0dc2286ca53
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Mar 2026 15:33:45 +0900

Avoid modal alerts

Prior to this commit, certain iCloud sync issues would cause an error
message to appear in a modal dialog box. This commit avoids modal
dialogs and instead uses a transient icon that appears in the toolbar
for both the iOS and macOS variations. This might still require further
tweaking.

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

Diffstat

MListless/Sync/CloudKitErrorClassifier.swift | 52++++------------------------------------------------
MListless/Sync/CloudKitSyncMonitor.swift | 17+----------------
MListlessMac/Extensions/TaskListView+Toolbar.swift | 14++++++++++++++
MListlessMac/ListlessMacApp.swift | 2+-
MListlessMac/Views/TaskListView.swift | 27---------------------------
MListlessiOS/Extensions/TaskListView+NavigationHeader.swift | 10++++++++++
MListlessiOS/Views/TaskListView.swift | 43-------------------------------------------
MTests/Unit/CloudKitErrorClassifierTests.swift | 45+++++++++++++++++++++------------------------

8 files changed, 51 insertions(+), 159 deletions(-)

diff --git a/Listless/Sync/CloudKitErrorClassifier.swift b/Listless/Sync/CloudKitErrorClassifier.swift

@@ -1,21 +1,9 @@
 import CloudKit
 import Foundation
 
-enum SyncAlertAction {
-    case openSettings
-}
-
-struct SyncAlertItem: Identifiable {
-    let id = UUID()
-    let title: String
-    let message: String
-    let action: SyncAlertAction?
-}
-
 enum SyncIssue {
     case transient(message: String)
     case deferred(message: String)
-    case alert(SyncAlertItem)
 }
 
 enum CloudKitErrorClassifier {
@@ -29,21 +17,7 @@ enum CloudKitErrorClassifier {
             return classifyCloudKit(code: ckCode)
         }
 
-        if nsError.domain == NSCocoaErrorDomain {
-            return .alert(
-                SyncAlertItem(
-                    title: "Unable to Save Changes",
-                    message: "Your changes are still local, but syncing encountered an issue. Please try again.",
-                    action: nil
-                ))
-        }
-
-        return .alert(
-            SyncAlertItem(
-                title: "Sync Error",
-                message: rootError.localizedDescription,
-                action: nil
-            ))
+        return .transient(message: "Saved locally. iCloud sync will retry automatically.")
     }
 
     private static func classifyCloudKit(code: CKError.Code) -> SyncIssue {
@@ -53,31 +27,13 @@ enum CloudKitErrorClassifier {
             return .transient(message: "Saved locally. iCloud sync will retry automatically.")
 
         case .notAuthenticated:
-            return .alert(
-                SyncAlertItem(
-                    title: "iCloud Sign-In Required",
-                    message:
-                        "Sign in to iCloud in Settings to continue syncing your tasks across devices.",
-                    action: .openSettings
-                ))
+            return .transient(message: "Sign in to iCloud to sync across devices.")
 
         case .quotaExceeded:
-            return .alert(
-                SyncAlertItem(
-                    title: "iCloud Storage Full",
-                    message:
-                        "Free up iCloud storage or upgrade your plan to continue syncing.",
-                    action: .openSettings
-                ))
+            return .transient(message: "iCloud storage full. Free up space to continue syncing.")
 
         case .permissionFailure, .badContainer, .missingEntitlement:
-            return .alert(
-                SyncAlertItem(
-                    title: "iCloud Sync Unavailable",
-                    message:
-                        "This device currently cannot access iCloud for syncing. Check iCloud settings and try again.",
-                    action: .openSettings
-                ))
+            return .transient(message: "iCloud sync unavailable. Check iCloud settings.")
 
         case .accountTemporarilyUnavailable, .zoneNotFound, .userDeletedZone:
             return .deferred(message: "Saved locally. iCloud sync will retry automatically.")

diff --git a/Listless/Sync/CloudKitSyncMonitor.swift b/Listless/Sync/CloudKitSyncMonitor.swift

@@ -12,7 +12,6 @@ struct SyncDiagnosticEntry: Identifiable {
 @MainActor
 final class CloudKitSyncMonitor: ObservableObject {
     @Published private(set) var transientErrorMessage: String?
-    @Published var actionableAlert: SyncAlertItem?
     @Published private(set) var lastSuccessfulSyncDate: Date?
     @Published private(set) var lastCloudKitErrorDomain: String?
     @Published private(set) var lastCloudKitErrorCode: Int?
@@ -25,7 +24,7 @@ final class CloudKitSyncMonitor: ObservableObject {
     private let maxDiagnosticEntries = 80
 
     var hasDiagnosticsIssue: Bool {
-        transientErrorMessage != nil || actionableAlert != nil || lastCloudKitErrorDescription != nil
+        transientErrorMessage != nil || lastCloudKitErrorDescription != nil
     }
 
     deinit {
@@ -79,10 +78,6 @@ final class CloudKitSyncMonitor: ObservableObject {
         }
     }
 
-    func clearActionableAlert() {
-        actionableAlert = nil
-    }
-
     func ingest(error: Error) {
         handle(issue: CloudKitErrorClassifier.classify(error))
     }
@@ -105,16 +100,6 @@ final class CloudKitSyncMonitor: ObservableObject {
                 self.logger.warning("Deferred sync issue surfaced: \(message, privacy: .public)")
                 self.appendDiagnostic(level: "warning", "Deferred sync issue surfaced: \(message)")
             }
-
-        case .alert(let alert):
-            logger.error(
-                "Actionable sync alert: title=\(alert.title, privacy: .public) message=\(alert.message, privacy: .public)"
-            )
-            appendDiagnostic(
-                level: "error",
-                "Actionable sync alert: \(alert.title) - \(alert.message)"
-            )
-            actionableAlert = alert
         }
     }
 

diff --git a/ListlessMac/Extensions/TaskListView+Toolbar.swift b/ListlessMac/Extensions/TaskListView+Toolbar.swift

@@ -9,6 +9,20 @@ extension TaskListView {
 
         ToolbarItemGroup(placement: .automatic) {
             HStack {
+                if syncMonitor.hasDiagnosticsIssue {
+                    Button {
+                        NSApp.sendAction(
+                            #selector(AppDelegate.handleShowSyncDiagnostics),
+                            to: nil, from: nil
+                        )
+                    } label: {
+                        Label("Sync Issues", systemImage: "exclamationmark.icloud")
+                    }
+                    .help("View sync diagnostics")
+
+                    Divider()
+                }
+
                 Button {
                     createNewTask()
                     // Trigger focus resolution by setting to nil

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -101,7 +101,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         MenuCoordinator.shared.clearCompletedTasks?()
     }
 
-    @objc private func handleShowSyncDiagnostics() {
+    @objc func handleShowSyncDiagnostics() {
         openSyncDiagnosticsWindow()
     }
 

diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift

@@ -336,33 +336,6 @@ struct TaskListView: View, TaskListViewProtocol {
         .overlay(alignment: .top) {
             syncErrorBanner
         }
-        .alert(
-            item: Binding(
-                get: { syncMonitor.actionableAlert },
-                set: { if $0 == nil { syncMonitor.clearActionableAlert() } }
-            )
-        ) { alert in
-            switch alert.action {
-            case .openSettings:
-                return Alert(
-                    title: Text(alert.title),
-                    message: Text(alert.message),
-                    primaryButton: .default(Text("Open Settings")) { openSystemSettings() },
-                    secondaryButton: .cancel(Text("OK")) {
-                        syncMonitor.clearActionableAlert()
-                    }
-                )
-
-            case .none:
-                return Alert(
-                    title: Text(alert.title),
-                    message: Text(alert.message),
-                    dismissButton: .default(Text("OK")) {
-                        syncMonitor.clearActionableAlert()
-                    }
-                )
-            }
-        }
     }
 }
 

diff --git a/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift b/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift

@@ -7,6 +7,16 @@ extension TaskListView {
                 .font(.largeTitle)
                 .fontWeight(.bold)
             Spacer()
+            if syncMonitor.hasDiagnosticsIssue {
+                Button {
+                    showSyncDiagnostics()
+                } label: {
+                    Image(systemName: "exclamationmark.icloud")
+                        .font(.title2)
+                        .foregroundStyle(.red)
+                }
+                .buttonStyle(.plain)
+            }
             Button {
                 showSettings()
             } label: {

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -231,22 +231,6 @@ struct TaskListView: View, TaskListViewProtocol {
             .overlay(alignment: .top) {
                 syncErrorBanner
             }
-            .overlay(alignment: .topTrailing) {
-                if syncMonitor.hasDiagnosticsIssue {
-                    Button {
-                        showSyncDiagnostics()
-                    } label: {
-                        Label("Sync Details", systemImage: "exclamationmark.icloud")
-                            .font(.caption)
-                            .padding(.horizontal, 10)
-                            .padding(.vertical, 7)
-                            .background(.thinMaterial)
-                            .clipShape(Capsule())
-                    }
-                    .padding(.top, 10)
-                    .padding(.trailing, 12)
-                }
-            }
             .overlay(alignment: .bottom) {
                 if let toast = iState.undoToast {
                     UndoToastView(
@@ -280,33 +264,6 @@ struct TaskListView: View, TaskListViewProtocol {
             ) {
                 SettingsView(syncMonitor: syncMonitor)
             }
-            .alert(
-                item: Binding(
-                    get: { syncMonitor.actionableAlert },
-                    set: { if $0 == nil { syncMonitor.clearActionableAlert() } }
-                )
-            ) { alert in
-                switch alert.action {
-                case .openSettings:
-                    return Alert(
-                        title: Text(alert.title),
-                        message: Text(alert.message),
-                        primaryButton: .default(Text("Open Settings")) { openSystemSettings() },
-                        secondaryButton: .cancel(Text("OK")) {
-                            syncMonitor.clearActionableAlert()
-                        }
-                    )
-
-                case .none:
-                    return Alert(
-                        title: Text(alert.title),
-                        message: Text(alert.message),
-                        dismissButton: .default(Text("OK")) {
-                            syncMonitor.clearActionableAlert()
-                        }
-                    )
-                }
-            }
     }
 
     private var taskScrollView: some View {

diff --git a/Tests/Unit/CloudKitErrorClassifierTests.swift b/Tests/Unit/CloudKitErrorClassifierTests.swift

@@ -53,36 +53,34 @@ struct CloudKitErrorClassifierTests {
         #expect(message.contains("retry"))
     }
 
-    // MARK: - Actionable Alerts
+    // MARK: - Transient Actionable Errors
 
-    @Test("Not authenticated requires sign-in")
+    @Test("Not authenticated is transient with sign-in message")
     func notAuthenticated() {
         let error = CKError(.notAuthenticated)
         let issue = CloudKitErrorClassifier.classify(error)
 
-        guard case .alert(let alert) = issue else {
-            Issue.record("Expected .alert, got \(issue)")
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
             return
         }
-        #expect(alert.title.contains("Sign-In"))
-        #expect(alert.action == .openSettings)
+        #expect(message.contains("Sign in"))
     }
 
-    @Test("Quota exceeded shows storage full")
+    @Test("Quota exceeded is transient with storage message")
     func quotaExceeded() {
         let error = CKError(.quotaExceeded)
         let issue = CloudKitErrorClassifier.classify(error)
 
-        guard case .alert(let alert) = issue else {
-            Issue.record("Expected .alert, got \(issue)")
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
             return
         }
-        #expect(alert.title.contains("Storage Full"))
-        #expect(alert.action == .openSettings)
+        #expect(message.contains("storage full"))
     }
 
     @Test(
-        "Permission errors show unavailable",
+        "Permission errors are transient with unavailable message",
         arguments: [
             CKError.Code.permissionFailure,
             CKError.Code.badContainer,
@@ -93,12 +91,11 @@ struct CloudKitErrorClassifierTests {
         let error = CKError(code)
         let issue = CloudKitErrorClassifier.classify(error)
 
-        guard case .alert(let alert) = issue else {
-            Issue.record("Expected .alert, got \(issue)")
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
             return
         }
-        #expect(alert.title.contains("Unavailable"))
-        #expect(alert.action == .openSettings)
+        #expect(message.contains("unavailable"))
     }
 
     // MARK: - Default / Unknown CKError
@@ -117,27 +114,27 @@ struct CloudKitErrorClassifierTests {
 
     // MARK: - Non-CloudKit Errors
 
-    @Test("Core Data error shows save failure alert")
+    @Test("Core Data error is transient")
     func coreDataError() {
         let error = NSError(domain: NSCocoaErrorDomain, code: 1570, userInfo: nil)
         let issue = CloudKitErrorClassifier.classify(error)
 
-        guard case .alert(let alert) = issue else {
-            Issue.record("Expected .alert, got \(issue)")
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
             return
         }
-        #expect(alert.title == "Unable to Save Changes")
+        #expect(message.contains("retry"))
     }
 
-    @Test("Unknown domain error shows generic sync error")
+    @Test("Unknown domain error is transient")
     func unknownDomainError() {
         let error = NSError(domain: "com.example.unknown", code: 42, userInfo: nil)
         let issue = CloudKitErrorClassifier.classify(error)
 
-        guard case .alert(let alert) = issue else {
-            Issue.record("Expected .alert, got \(issue)")
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
             return
         }
-        #expect(alert.title == "Sync Error")
+        #expect(message.contains("retry"))
     }
 }