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