listless

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

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")) } }