diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index d849a5a..b7a0dcb 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -3,7 +3,9 @@ name: iOS on: push: branches: + - main - ios-companion + - ios-storage-hardening paths: - "iOS/**" - "Sources/DrawerCore/**" @@ -66,17 +68,57 @@ jobs: python3 - <<'PY' import plistlib - expected = ["group.com.bbrizly.drawer"] - paths = [ + + expected_group = ["group.com.bbrizly.drawer"] + entitlement_paths = [ "iOS/DrawerMobile/Resources/DrawerMobile.entitlements", "iOS/DrawerWidgets/Resources/DrawerWidgets.entitlements", ] - for path in paths: + for path in entitlement_paths: with open(path, "rb") as handle: payload = plistlib.load(handle) actual = payload.get("com.apple.security.application-groups") - if actual != expected: - raise SystemExit(f"{path}: expected App Group {expected}, found {actual}") + if actual != expected_group: + raise SystemExit(f"{path}: expected App Group {expected_group}, found {actual}") + + privacy_paths = [ + "iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy", + "iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy", + ] + for path in privacy_paths: + with open(path, "rb") as handle: + payload = plistlib.load(handle) + if payload.get("NSPrivacyTracking") is not False: + raise SystemExit(f"{path}: Drawer must declare tracking disabled") + if payload.get("NSPrivacyCollectedDataTypes") != []: + raise SystemExit(f"{path}: unexpected collected-data declaration") + api_types = payload.get("NSPrivacyAccessedAPITypes", []) + user_defaults = next( + (entry for entry in api_types + if entry.get("NSPrivacyAccessedAPIType") == "NSPrivacyAccessedAPICategoryUserDefaults"), + None, + ) + reasons = set((user_defaults or {}).get("NSPrivacyAccessedAPITypeReasons", [])) + if "1C8F.1" not in reasons: + raise SystemExit(f"{path}: App Group UserDefaults use must declare reason 1C8F.1") + + with open("iOS/DrawerMobile/Resources/Info.plist", "rb") as handle: + app_info = plistlib.load(handle) + if app_info.get("NSSupportsLiveActivities") is not True: + raise SystemExit("DrawerMobile Info.plist must enable Live Activities") + schemes = { + scheme + for entry in app_info.get("CFBundleURLTypes", []) + for scheme in entry.get("CFBundleURLSchemes", []) + } + if "drawer" not in schemes: + raise SystemExit("DrawerMobile Info.plist must register the drawer URL scheme") + + with open("iOS/DrawerWidgets/Resources/Info.plist", "rb") as handle: + widget_info = plistlib.load(handle) + extension_point = widget_info.get("NSExtension", {}).get("NSExtensionPointIdentifier") + if extension_point != "com.apple.widgetkit-extension": + raise SystemExit(f"Unexpected widget extension point: {extension_point}") PY ICON="iOS/DrawerMobile/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png" diff --git a/Docs/IOS.md b/Docs/IOS.md index 9784c21..0ee074c 100644 --- a/Docs/IOS.md +++ b/Docs/IOS.md @@ -117,19 +117,42 @@ Drawer document access adapter WidgetKit timeline ``` +### Storage and sync contract + +Drawer does not own a cloud service and does not assume that Obsidian owns the file path. The user selects one canonical `Drawer.md` through Files and that grant may resolve to: + +- an On My iPhone / local Files item +- an Obsidian local vault +- an Obsidian Sync vault's local copy, when that file is exposed through Files +- `iCloud Drive/Obsidian//Drawer.md` +- another third-party Files provider + +The access adapter classifies only what iOS can prove. An item that reports `isUbiquitousItem == true` receives iCloud-specific freshness handling; every other document-picker source stays on the generic Files path rather than relying on private path heuristics. + +For iCloud, canonical reads and writes are permitted only when the local item is current. Apple's `downloaded` state means a local copy exists but is stale, while `notDownloaded` means no local copy exists. In either state Drawer requests `startDownloadingUbiquitousItem` and waits rather than reading stale bytes or writing over a newer cloud revision. An unresolved iCloud document conflict also blocks canonical mutation until the user resolves it in Files/Obsidian. + +A document-picker grant and a usable canonical source are deliberately different states. If a newly chosen source is not safely readable yet, Drawer persists it as a **staged bookmark** in the App Group but does not replace the primary bookmark. The staged source survives process death and is promoted only after a current coordinated read succeeds and the bytes are valid UTF-8 Markdown. If a previous `Drawer.md` exists, that previous source remains the app/widget mutation target throughout staging. A terminal failure discards only the staged replacement and leaves the previous source intact. + +Transient iCloud/provider states are retried with suspended `Task` delays only while Drawer is foregrounded, with the interval capped at five seconds; no main-thread sleep is used. Authentication and iCloud-conflict states preserve the staged or active grant but do not busy-poll: Drawer retries when the scene becomes active again after the user fixes the provider state. Permission loss, missing files, invalid content, quota/collision, and unrelated provider failures are not mislabeled as temporary connectivity problems. + +For generic third-party Files providers, `NSFileCoordinator` remains the authority. There is no universal client API equivalent to iCloud's materialization API for every provider, so unavailable/authentication states preserve the selected grant and widget cache while all mutation paths fail closed. + ### Shared core Keep `TodoParser`, `TodoWriteback`, `TodoItem`, planning, timer models, and other deterministic behavior in `DrawerCore`. Add an iOS-compatible document boundary rather than teaching core logic about UIKit or WidgetKit. The mobile adapter owns: -- selected file bookmark +- primary + staged selected-file bookmarks +- transactional promotion / rollback of source changes - coordinated reads/writes (`NSFileCoordinator`) - foreground file presentation / external-change notifications (`NSFilePresenter`) - stale/invalid bookmark recovery +- iCloud freshness/materialization and unresolved-conflict refusal +- transient File Provider recovery without destroying the saved source - content-CAS retry before every canonical write, preserving Drawer’s existing no-clobber invariant -A replacement file bookmark is committed only after the selected file can actually be read as UTF-8 Markdown, so a bad Change Drawer.md selection cannot discard the last known-good connection. +A replacement file bookmark becomes canonical only after the selected file can actually be read as current UTF-8 Markdown, so a bad, evicted, signed-out, or conflicted Change Drawer.md selection cannot discard the last known-good connection. Staged grants that can recover without another picker visit are retained across relaunch. Apple’s iOS file model returns externally selected URLs through the document picker, and persistent bookmarks are platform-specific. Relaunch/reboot/iCloud/File Provider behavior remains a physical-device integration gate rather than something inferred from macOS bookmark semantics. @@ -137,19 +160,22 @@ Apple’s iOS file model returns externally selected URLs through the document p `DrawerMobileModel` is MainActor-owned. It: -- resolves the selected document +- resolves the selected canonical document and any staged replacement independently - reloads + parses via `DrawerCore` - exposes Today / Carried / Upcoming / Backlog +- performs mutation transforms only against the primary canonical document - performs mutation transforms against the freshest coordinated bytes - applies the same fresh-byte recheck to automatic recurrence reconciliation / completed-task normalization before those paths write canonical Markdown - publishes a widget snapshot only after a successful canonical read/write - reports auxiliary widget-cache failure separately without treating the canonical save as failed +- retains the last-known-good task UI during transient provider materialization/offline states and retries without blocking the main actor +- resumes a staged replacement after relaunch and promotes it only after validation - maintains a one-action undo payload for destructive/move actions and clears that payload on source-file changes - persists/restores absolute Focus state across scene suspension and process relaunch ### Widget snapshot -The App Group stores a tiny, versioned last-known-good snapshot. Widget timeline generation renders safely from that snapshot and opportunistically refreshes it from the selected canonical `Drawer.md` when the extension can resolve the security-scoped bookmark. If the File Provider is unavailable—or the external file is temporarily not valid UTF-8—the widget preserves the last known-good snapshot instead of inventing an empty state. +The App Group stores a tiny, versioned last-known-good snapshot. Widget timeline generation renders safely from that snapshot and opportunistically refreshes it from the **primary** canonical `Drawer.md` when the extension can resolve the security-scoped bookmark. A staged replacement is intentionally invisible to WidgetKit until promotion. If the File Provider is unavailable, iCloud is still materializing the primary file, an iCloud conflict exists, or the external file is temporarily not valid UTF-8, the widget preserves the last known-good snapshot instead of inventing an empty or stale task state. ```swift struct WidgetSnapshot: Codable { @@ -164,9 +190,9 @@ struct WidgetSnapshot: Codable { } ``` -Interactive intents use the same canonical mutation path. On success they rebuild the snapshot and ask WidgetKit to reload. On failure they leave the snapshot untouched, record a short-lived recovery state, and the widget explicitly says the update failed / opens Drawer for recovery. Mutable widget content is marked invalidatable while WidgetKit reloads. This is critical: no UI-only completion state. +Interactive intents use the same canonical mutation path. On success they rebuild the snapshot and ask WidgetKit to reload. On failure they leave the snapshot untouched, record a short-lived provider-specific recovery state, and the widget explicitly explains whether the file is syncing, the provider is unavailable, or reconnection is required. Mutable widget content is marked invalidatable while WidgetKit reloads. This is critical: no UI-only completion state. -Disconnect removes the shared snapshot and immediately reloads WidgetKit so old task text is not intentionally left on the Home or Lock Screen after the source is disconnected. +Disconnect removes both primary/staged bookmarks plus the shared snapshot and immediately reloads WidgetKit so old task text is not intentionally left on the Home or Lock Screen after the source is disconnected. External-file bookmark access from an app-extension process remains provider/OS-sensitive. If the extension cannot safely regain access to `Drawer.md`, the interaction fails closed; Drawer never marks the cached task complete without a canonical write. diff --git a/iOS/DrawerMobile/App/DrawerMobileApp.swift b/iOS/DrawerMobile/App/DrawerMobileApp.swift index 0fde452..fdba9de 100644 --- a/iOS/DrawerMobile/App/DrawerMobileApp.swift +++ b/iOS/DrawerMobile/App/DrawerMobileApp.swift @@ -8,7 +8,17 @@ struct DrawerMobileApp: App { var body: some Scene { WindowGroup { DrawerRootView(model: model) - .task { model.bootstrap() } + .task { + // ActivityKit and pending local notifications can outlive a + // process. If there is no persisted Focus session after the + // model's restore pass, clear any orphan system surface. + // A legitimate restored session remains untouched because + // it has already repopulated DrawerFocusStore. + if DrawerFocusStore.load() == nil { + FocusNotificationScheduler.cancel() + } + model.bootstrap() + } .onOpenURL { url in guard url.scheme == "drawer" else { return } if url.host == "capture" || url.path == "/capture" { diff --git a/iOS/DrawerMobile/App/DrawerRootView.swift b/iOS/DrawerMobile/App/DrawerRootView.swift index 68a013c..6b5cf9a 100644 --- a/iOS/DrawerMobile/App/DrawerRootView.swift +++ b/iOS/DrawerMobile/App/DrawerRootView.swift @@ -21,14 +21,23 @@ struct DrawerRootView: View { case .loading: ProgressView() .controlSize(.large) + .accessibilityLabel("Opening Drawer") case .connected: DrawerHomeView( model: model, changeFile: { showingImporter = true } ) + case .waitingForProvider: + DrawerConnectionView( + needsPermission: false, + waitingForProvider: true, + message: model.statusMessage, + chooseFile: { showingImporter = true } + ) case .disconnected, .needsPermission: DrawerConnectionView( needsPermission: model.connectionState == .needsPermission, + waitingForProvider: false, message: model.statusMessage, chooseFile: { showingImporter = true } ) @@ -48,8 +57,7 @@ struct DrawerRootView: View { nsError.code == CocoaError.userCancelled.rawValue { return } - model.statusMessage = error.localizedDescription - DrawerHaptics.shared.error() + model.reportError(error) } } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index 2412830..ad2a57b 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -1,39 +1,75 @@ +import ActivityKit import Foundation import UserNotifications +@MainActor enum FocusNotificationScheduler { private static let identifier = "drawer.focus.complete" + private static var generation: UInt = 0 static func schedule(taskTitle: String, seconds: TimeInterval) { - guard seconds > 1 else { return } - Task { + generation &+= 1 + let scheduledGeneration = generation + let scheduledSessionID = DrawerFocusStore.load()?.id + + reconcileLiveActivity() + + // A new Focus replaces the one global completion alert. Remove the old + // request before any notification-settings/authorization suspension so + // a prior session can never survive while the new schedule is waiting. + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: [identifier] + ) + + guard seconds > 1, let scheduledSessionID else { return } + + Task { @MainActor in let center = UNUserNotificationCenter.current() var settings = await center.notificationSettings() + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { return } + if settings.authorizationStatus == .notDetermined { do { _ = try await center.requestAuthorization(options: [.alert, .sound]) } catch { - await DrawerActionFeedbackCenter.notice( + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { return } + DrawerActionFeedbackCenter.notice( "Focus is running, but the completion alert couldn't be enabled.", systemImage: "bell.slash.fill" ) return } + + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { return } + settings = await center.notificationSettings() } + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { return } + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { - await DrawerActionFeedbackCenter.notice( + DrawerActionFeedbackCenter.notice( "Focus is running; completion notifications are off.", systemImage: "bell.slash.fill" ) return } - center.removePendingNotificationRequests(withIdentifiers: [identifier]) let content = UNMutableNotificationContent() content.title = "Focus complete" content.body = taskTitle.isEmpty ? "Time's up." : taskTitle @@ -50,8 +86,20 @@ enum FocusNotificationScheduler { do { try await center.add(request) + + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + return + } } catch { - await DrawerActionFeedbackCenter.notice( + guard isCurrentRunningSchedule( + generation: scheduledGeneration, + sessionID: scheduledSessionID + ) else { return } + DrawerActionFeedbackCenter.notice( "Focus is running, but the completion alert couldn't be scheduled.", systemImage: "bell.slash.fill" ) @@ -60,8 +108,163 @@ enum FocusNotificationScheduler { } static func cancel() { + generation &+= 1 + reconcileLiveActivity() UNUserNotificationCenter.current().removePendingNotificationRequests( withIdentifiers: [identifier] ) } + + private static func isCurrentRunningSchedule( + generation scheduledGeneration: UInt, + sessionID: UUID + ) -> Bool { + guard generation == scheduledGeneration, + let current = DrawerFocusStore.load(), + current.id == sessionID, + current.phase == .running + else { return false } + return true + } + + private static func reconcileLiveActivity() { + let persistedFocus = DrawerFocusStore.load() + Task { + await DrawerFocusLiveActivityManager.shared.reconcile(persistedFocus) + } + } +} + +/// Serializes ActivityKit mutations so rapid pause/resume taps cannot reorder +/// Live Activity state. It only mirrors DrawerFocusStore; it never owns timer +/// truth and never completes a Markdown task. +actor DrawerFocusLiveActivityManager { + static let shared = DrawerFocusLiveActivityManager() + + func reconcile(_ focus: DrawerPersistedFocus?) async { + guard let focus else { + await endAll(immediately: true) + return + } + + let matching = Activity.activities.first { + $0.attributes.sessionID == focus.id + } + + for activity in Activity.activities + where activity.attributes.sessionID != focus.id { + await activity.end(nil, dismissalPolicy: .immediate) + } + + switch focus.phase { + case .running: + guard let endDate = focus.endDate else { + if let matching { await matching.end(nil, dismissalPolicy: .immediate) } + return + } + if endDate <= Date() { + await finishExisting(matching, remaining: 0) + } else if let matching { + await matching.update( + ActivityContent( + state: .init(phase: .running, endDate: endDate, remaining: focus.remaining), + staleDate: endDate + ) + ) + } else { + await start(focus, endDate: endDate) + } + + case .paused: + if let matching { + await matching.update( + ActivityContent( + state: .init(phase: .paused, endDate: nil, remaining: focus.remaining), + staleDate: nil + ) + ) + } else { + await start(focus, endDate: nil) + } + + case .finished: + await finishExisting(matching, remaining: 0) + } + } + + func end(sessionID: UUID?, completed: Bool) async { + let activities = Activity.activities.filter { + sessionID == nil || $0.attributes.sessionID == sessionID + } + let phase: DrawerFocusActivityAttributes.ContentState.Phase = completed ? .finished : .ended + let final = ActivityContent( + state: DrawerFocusActivityAttributes.ContentState( + phase: phase, + endDate: nil, + remaining: 0 + ), + staleDate: nil + ) + let policy: ActivityUIDismissalPolicy = completed + ? .after(Date().addingTimeInterval(5 * 60)) + : .immediate + + for activity in activities { + await activity.end(final, dismissalPolicy: policy) + } + } + + private func start(_ focus: DrawerPersistedFocus, endDate: Date?) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let phase: DrawerFocusActivityAttributes.ContentState.Phase = endDate == nil ? .paused : .running + let attributes = DrawerFocusActivityAttributes( + sessionID: focus.id, + taskTitle: focus.taskTitle + ) + let state = DrawerFocusActivityAttributes.ContentState( + phase: phase, + endDate: endDate, + remaining: focus.remaining + ) + + do { + _ = try Activity.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: endDate), + pushType: nil + ) + } catch { + // Live Activities are an ambient enhancement. Focus remains fully + // functional if the user disabled them or ActivityKit refuses one. + } + } + + private func finishExisting( + _ activity: Activity?, + remaining: TimeInterval + ) async { + guard let activity else { return } + let final = ActivityContent( + state: DrawerFocusActivityAttributes.ContentState( + phase: .finished, + endDate: nil, + remaining: remaining + ), + staleDate: nil + ) + await activity.end( + final, + dismissalPolicy: .after(Date().addingTimeInterval(5 * 60)) + ) + } + + private func endAll(immediately: Bool) async { + for activity in Activity.activities { + await activity.end( + nil, + dismissalPolicy: immediately ? .immediate : .default + ) + } + } } diff --git a/iOS/DrawerMobile/Haptics/DrawerHaptics.swift b/iOS/DrawerMobile/Haptics/DrawerHaptics.swift index d33e4cf..68832e1 100644 --- a/iOS/DrawerMobile/Haptics/DrawerHaptics.swift +++ b/iOS/DrawerMobile/Haptics/DrawerHaptics.swift @@ -165,18 +165,21 @@ final class DrawerHaptics { } } +/// Press treatment should read as physical depth, not as a disabled/faded +/// control. The scale change carries most of the tactile illusion; opacity only +/// softens very slightly so text/icons remain crisp through rapid repeated taps. struct TactileButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) private var reduceMotion var pressedScale: CGFloat = 0.975 - var pressedOpacity: Double = 0.9 + var pressedOpacity: Double = 0.96 func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? pressedScale : 1) .opacity(configuration.isPressed ? pressedOpacity : 1) .animation( - reduceMotion ? nil : .spring(response: 0.18, dampingFraction: 0.72), + reduceMotion ? nil : .spring(response: 0.15, dampingFraction: 0.78), value: configuration.isPressed ) } diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index 285d0f7..4fd582d 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -9,9 +9,16 @@ final class DrawerMobileModel: ObservableObject { case loading case disconnected case connected + case waitingForProvider case needsPermission } + enum StatusTone: Equatable { + case info + case warning + case error + } + struct UndoPayload { let label: String let originalData: Data @@ -25,26 +32,36 @@ final class DrawerMobileModel: ObservableObject { @Published private(set) var backlogItems: [TodoItem] = [] @Published private(set) var upcomingLabel = "" @Published private(set) var sourceName = "Drawer.md" - @Published var statusMessage: String? + @Published private(set) var statusMessage: String? + @Published private(set) var statusTone: StatusTone = .info @Published private(set) var undoLabel: String? @Published private(set) var captureRequestToken = 0 let focusTimer = FocusTimer() private var document: CoordinatedDrawerDocument? + private var pendingDocument: CoordinatedDrawerDocument? private var lastAppliedData: Data? private var lastAppliedDayKey: String? private var undoPayload: UndoPayload? private var undoExpiryTask: Task? + private var providerRetryTask: Task? + private var pendingRetryTask: Task? + private var pendingStatusMessage: String? + private var pendingStatusTone: StatusTone = .info + private var hasTransientAccessFailure = false private var isSceneActive = true private var focusSessionID: UUID? private var focusCreatedAt: Date? init() { focusTimer.onComplete = { [weak self] _ in + // The timer has already entered .finished here. Persist that truth + // first, then let the scheduler reconcile the same stored state to + // ActivityKit while removing the pending completion notification. + self?.persistFocusState() FocusNotificationScheduler.cancel() DrawerHaptics.shared.focusFinished() - self?.persistFocusState() } restoreFocusState() } @@ -52,6 +69,14 @@ final class DrawerMobileModel: ObservableObject { var connectedFileURL: URL? { document?.url } func bootstrap() { + if DrawerBookmarkStore.hasPendingBookmark { + if DrawerBookmarkStore.hasBookmark { + openStoredDocument() + } + beginPendingSelection() + return + } + guard DrawerBookmarkStore.hasBookmark else { WidgetInteractionFeedbackStore.clear() connectionState = .disconnected @@ -62,14 +87,17 @@ final class DrawerMobileModel: ObservableObject { func connect(to pickedURL: URL) { do { - try DrawerBookmarkStore.save(pickedURL) - openStoredDocument() + switch try DrawerBookmarkStore.save(pickedURL) { + case .ready: + openStoredDocument() + case .staged: + beginPendingSelection() + } } catch { - // Change Drawer.md is transactional: if a new selection fails - // validation, keep the already-open source usable rather than - // replacing the whole app with a reconnect screen. if document != nil { connectionState = .connected + } else if pendingDocument != nil || DrawerBookmarkStore.hasPendingBookmark { + connectionState = .waitingForProvider } else { connectionState = DrawerBookmarkStore.hasBookmark ? .needsPermission : .disconnected } @@ -77,10 +105,17 @@ final class DrawerMobileModel: ObservableObject { } } + func reportError(_ error: Error) { + fail(error) + } + func disconnect() { document?.stopObserving() document = nil clearUndo() + cancelProviderRetry() + clearPendingRuntime() + hasTransientAccessFailure = false lastAppliedData = nil lastAppliedDayKey = nil carriedItems = [] @@ -88,7 +123,7 @@ final class DrawerMobileModel: ObservableObject { upcomingItems = [] backlogItems = [] upcomingLabel = "" - statusMessage = nil + clearStatus() DrawerBookmarkStore.clear() WidgetInteractionFeedbackStore.clear() if let snapshotURL = WidgetSnapshotStore.snapshotURL { @@ -101,13 +136,23 @@ final class DrawerMobileModel: ObservableObject { func setSceneActive(_ active: Bool) { isSceneActive = active focusTimer.setDisplayActive(active) - if !active { persistFocusState() } - guard let document else { return } - if active { - startObserving(document) - reload() - } else { - document.stopObserving() + if !active { + persistFocusState() + cancelProviderRetry() + cancelPendingRetry() + } + + if let document { + if active { + startObserving(document) + reload() + } else { + document.stopObserving() + } + } + + if active, pendingDocument != nil { + attemptPendingSelection() } } @@ -126,12 +171,15 @@ final class DrawerMobileModel: ObservableObject { do { let today = DrawerDate.todayKey() var base = try document.read() - if base == lastAppliedData, today == lastAppliedDayKey { return } + if base == lastAppliedData, today == lastAppliedDayKey { + if hasTransientAccessFailure { + hasTransientAccessFailure = false + } + cancelProviderRetry() + restorePendingStatus() + return + } - // Automatic recurrence/archive normalization is a canonical write, - // so it follows the same one-retry content-CAS rule as a user - // mutation. If Obsidian/iCloud changed Drawer.md after the first - // read, recompute against those fresh bytes before writing. var normalized = try normalizedData(base, today: today) if normalized != base { let fresh = try document.read() @@ -157,11 +205,11 @@ final class DrawerMobileModel: ObservableObject { @discardableResult func toggle(_ item: TodoItem) -> Bool { - // A completed recurring occurrence already has a successor. Reopening - // it would create two active members of one series, so history stays - // immutable until a dedicated series-history editor exists. if item.isDone, recurrence(for: item) != nil { - statusMessage = "Completed repeating occurrences stay in history. Edit the active copy instead." + setStatus( + "Completed repeating occurrences stay in history. Edit the active copy instead.", + tone: .warning + ) DrawerHaptics.shared.error() return false } @@ -274,17 +322,66 @@ final class DrawerMobileModel: ObservableObject { func rename(_ item: TodoItem, to newTitle: String) -> Bool { let title = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { return false } + let markdownTitle = item.minutes == 25 ? title : "\(title) (\(item.minutes)m)" return commit { data in try TodoWriteback.rename( line: item.rawLine, sectionDate: item.sectionDate, occurrence: item.occurrence, - to: title, + to: markdownTitle, in: data ) } != nil } + @discardableResult + func updateTask( + _ item: TodoItem, + title newTitle: String, + minutes: Int, + note: String + ) -> Bool { + let title = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + setStatus("Task title can't be empty.", tone: .warning) + return false + } + guard (1...480).contains(minutes) else { + setStatus("Focus length must be between 1 and 480 minutes.", tone: .warning) + return false + } + + let cleanNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + let oldNote = (item.note ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let titleChanged = title != item.title || minutes != item.minutes + let noteChanged = cleanNote != oldNote + guard titleChanged || noteChanged else { return true } + + return commit { data in + var output = data + if noteChanged { + output = try TodoMetadataWriteback.setNote( + line: item.rawLine, + sectionDate: item.sectionDate, + occurrence: item.occurrence, + note: cleanNote, + in: output + ) + } + if titleChanged { + let markdownTitle = minutes == 25 ? title : "\(title) (\(minutes)m)" + output = try TodoWriteback.rename( + line: item.rawLine, + sectionDate: item.sectionDate, + occurrence: item.occurrence, + to: markdownTitle, + in: output + ) + } + return output + } != nil + } + @discardableResult func move(_ item: TodoItem, to destination: DrawerTaskDestination) -> Bool { let target: (key: String, heading: String) @@ -311,7 +408,7 @@ final class DrawerMobileModel: ObservableObject { ) }) else { return false } - armUndo(label: "Moved to \(destination.title)", original: result.before, expectedCurrent: result.after) + armUndoIfExact(label: "Moved to \(destination.title)", result: result) return true } @@ -326,7 +423,7 @@ final class DrawerMobileModel: ObservableObject { ) }) else { return false } - armUndo(label: "Deleted \(item.title)", original: result.before, expectedCurrent: result.after) + armUndoIfExact(label: "Deleted \(item.title)", result: result) return true } @@ -337,7 +434,7 @@ final class DrawerMobileModel: ObservableObject { let current = try document.read() guard current == payload.expectedCurrentData else { clearUndo() - statusMessage = "Couldn't undo because Drawer.md changed elsewhere." + setStatus("Couldn't undo because Drawer.md changed elsewhere.", tone: .warning) DrawerHaptics.shared.error() reload() return false @@ -399,7 +496,10 @@ final class DrawerMobileModel: ObservableObject { private struct CommitResult { let before: Data - let after: Data + let attempted: Data + let canonical: Data + + var canonicalMatchesAttempt: Bool { attempted == canonical } } private func commit(_ transform: (Data) throws -> Data) -> CommitResult? { @@ -421,10 +521,12 @@ final class DrawerMobileModel: ObservableObject { try document.write(output) let canonical = try document.read() apply(canonical) - return CommitResult(before: base, after: canonical) + return CommitResult(before: base, attempted: output, canonical: canonical) } catch { fail(error) - reload() + if (error as? DrawerFileAccessError)?.isTransient != true { + reload() + } return nil } } @@ -436,8 +538,6 @@ final class DrawerMobileModel: ObservableObject { var normalized = try TodoRecurrenceWriteback.reconcile(in: data, today: today) guard let normalizedText = String(data: normalized, encoding: .utf8) else { - // A deterministic transform must never turn valid canonical input - // into invalid text; treat it as a hard failure if it does. throw DrawerBookmarkError.invalidEncoding } let swept = TodoArchiver.archiveCompleted(in: normalizedText, today: today) @@ -449,7 +549,7 @@ final class DrawerMobileModel: ObservableObject { private func apply(_ data: Data) { guard let text = String(data: data, encoding: .utf8) else { - statusMessage = "Drawer.md isn't UTF-8 text." + setStatus("Drawer.md isn't UTF-8 text.", tone: .error) return } let today = DrawerDate.todayKey() @@ -463,7 +563,9 @@ final class DrawerMobileModel: ObservableObject { } else { upcomingLabel = "" } - statusMessage = nil + hasTransientAccessFailure = false + cancelProviderRetry() + restorePendingStatus() lastAppliedData = data lastAppliedDayKey = today publishWidgetSnapshot(data, today: today) @@ -475,7 +577,10 @@ final class DrawerMobileModel: ObservableObject { WidgetInteractionFeedbackStore.clear() WidgetCenter.shared.reloadAllTimelines() } catch { - statusMessage = "Drawer.md is safe, but widgets couldn't refresh. Check the App Group setup." + setStatus( + "Drawer.md is safe, but widgets couldn't refresh. Check the App Group setup.", + tone: .warning + ) DrawerActionFeedbackCenter.notice( "Saved to Drawer.md, but widgets couldn't refresh.", systemImage: "rectangle.stack.badge.exclamationmark" @@ -488,6 +593,9 @@ final class DrawerMobileModel: ObservableObject { let newDocument = CoordinatedDrawerDocument(session: try DrawerBookmarkStore.openSession()) document?.stopObserving() clearUndo() + cancelProviderRetry() + clearPendingRuntime() + hasTransientAccessFailure = false document = newDocument sourceName = newDocument.url.lastPathComponent connectionState = .connected @@ -502,14 +610,128 @@ final class DrawerMobileModel: ObservableObject { } } + private func beginPendingSelection() { + do { + let candidate = CoordinatedDrawerDocument(session: try DrawerBookmarkStore.openPendingSession()) + pendingDocument = candidate + pendingStatusMessage = "Getting the new Drawer.md ready." + pendingStatusTone = .info + sourceName = document?.url.lastPathComponent ?? candidate.url.lastPathComponent + + if document == nil { + connectionState = .waitingForProvider + carriedItems = [] + todayItems = [] + upcomingItems = [] + backlogItems = [] + upcomingLabel = "" + } else { + connectionState = .connected + restorePendingStatus() + } + + attemptPendingSelection() + } catch { + handlePendingSelectionFailure(error) + } + } + + private func attemptPendingSelection() { + guard let candidate = pendingDocument else { return } + + do { + let data = try candidate.read() + guard String(data: data, encoding: .utf8) != nil else { + throw DrawerBookmarkError.invalidEncoding + } + + try DrawerBookmarkStore.promotePending() + + document?.stopObserving() + clearUndo() + cancelProviderRetry() + cancelPendingRetry() + pendingStatusMessage = nil + pendingStatusTone = .info + hasTransientAccessFailure = false + document = candidate + pendingDocument = nil + sourceName = candidate.url.lastPathComponent + connectionState = .connected + lastAppliedData = nil + lastAppliedDayKey = nil + clearStatus() + if isSceneActive { startObserving(candidate) } + reload() + } catch let accessError as DrawerFileAccessError where accessError.preservesSelectedGrant { + pendingStatusMessage = pendingMessage(for: accessError) + pendingStatusTone = tone(for: accessError) + restorePendingStatus() + if document == nil { + connectionState = .waitingForProvider + } else { + connectionState = .connected + } + + if accessError.isTransient { + schedulePendingRetry() + } else { + cancelPendingRetry() + } + } catch { + handlePendingSelectionFailure(error) + } + } + + private func handlePendingSelectionFailure(_ error: Error) { + let detail = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + DrawerBookmarkStore.discardPending() + clearPendingRuntime() + + if document != nil { + connectionState = .connected + setStatus( + "Couldn't switch Drawer.md. \(detail) Your current file is still connected.", + tone: .error + ) + DrawerHaptics.shared.error() + return + } + + if DrawerBookmarkStore.hasBookmark { + openStoredDocument() + if document != nil { + setStatus( + "Couldn't switch Drawer.md. \(detail) Your previous file is still connected.", + tone: .error + ) + DrawerHaptics.shared.error() + return + } + } + + connectionState = DrawerBookmarkStore.hasBookmark ? .needsPermission : .disconnected + fail(error) + } + + private func pendingMessage(for error: DrawerFileAccessError) -> String { + let base = error.errorDescription ?? "Drawer.md isn't available yet." + guard document != nil else { return base } + return "\(base) Your current Drawer.md stays active until the new file is ready." + } + private func startObserving(_ document: CoordinatedDrawerDocument) { document.startObserving( onChange: { [weak self] in self?.reload() }, onMove: { [weak self] newURL in guard let self else { return } do { - try DrawerBookmarkStore.save(newURL) - self.openStoredDocument() + switch try DrawerBookmarkStore.save(newURL) { + case .ready: + self.openStoredDocument() + case .staged: + self.beginPendingSelection() + } } catch { self.fail(error) } @@ -520,8 +742,6 @@ final class DrawerMobileModel: ObservableObject { private func restoreFocusState() { guard let saved = DrawerFocusStore.load() else { return } - // A finished timer is useful briefly if the app was killed around the - // completion boundary, but it must not resurrect stale UI days later. let age = Date().timeIntervalSince(saved.createdAt) guard age >= 0, age < 24 * 60 * 60 else { DrawerFocusStore.clear() @@ -537,10 +757,12 @@ final class DrawerMobileModel: ObservableObject { DrawerFocusStore.clear() focusSessionID = nil focusCreatedAt = nil + FocusNotificationScheduler.cancel() return } focusTimer.restoreRunning(taskTitle: saved.taskTitle, endDate: endDate) if focusTimer.phase == .running { + persistFocusState() FocusNotificationScheduler.schedule( taskTitle: saved.taskTitle, seconds: focusTimer.remaining @@ -555,6 +777,7 @@ final class DrawerMobileModel: ObservableObject { FocusNotificationScheduler.cancel() case .finished: focusTimer.restoreFinished(taskTitle: saved.taskTitle) + persistFocusState() FocusNotificationScheduler.cancel() } } @@ -594,6 +817,18 @@ final class DrawerMobileModel: ObservableObject { ) } + private func armUndoIfExact(label: String, result: CommitResult) { + guard result.canonicalMatchesAttempt else { + clearUndo() + return + } + armUndo( + label: label, + original: result.before, + expectedCurrent: result.canonical + ) + } + private func armUndo(label: String, original: Data, expectedCurrent: Data) { undoExpiryTask?.cancel() undoPayload = UndoPayload(label: label, originalData: original, expectedCurrentData: expectedCurrent) @@ -612,8 +847,134 @@ final class DrawerMobileModel: ObservableObject { undoLabel = nil } + private func scheduleProviderRetry() { + guard isSceneActive, document != nil, providerRetryTask == nil else { return } + + providerRetryTask = Task { [weak self] in + let initialDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + .seconds(3), + ] + var attempt = 0 + + while !Task.isCancelled { + let delay = attempt < initialDelays.count ? initialDelays[attempt] : .seconds(5) + attempt += 1 + + do { + try await Task.sleep(for: delay) + } catch { + return + } + + guard let self, + self.isSceneActive, + self.document != nil, + self.hasTransientAccessFailure + else { return } + + self.reload() + if !self.hasTransientAccessFailure { return } + } + } + } + + private func schedulePendingRetry() { + guard isSceneActive, pendingDocument != nil, pendingRetryTask == nil else { return } + + pendingRetryTask = Task { [weak self] in + let initialDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + .seconds(3), + ] + var attempt = 0 + + while !Task.isCancelled { + let delay = attempt < initialDelays.count ? initialDelays[attempt] : .seconds(5) + attempt += 1 + + do { + try await Task.sleep(for: delay) + } catch { + return + } + + guard let self, + self.isSceneActive, + self.pendingDocument != nil + else { return } + + self.attemptPendingSelection() + if self.pendingDocument == nil { return } + } + } + } + + private func cancelProviderRetry() { + providerRetryTask?.cancel() + providerRetryTask = nil + } + + private func cancelPendingRetry() { + pendingRetryTask?.cancel() + pendingRetryTask = nil + } + + private func clearPendingRuntime() { + cancelPendingRetry() + pendingDocument = nil + pendingStatusMessage = nil + pendingStatusTone = .info + } + private func fail(_ error: Error) { - statusMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - DrawerHaptics.shared.error() + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + let nextTone = tone(for: error) + let changed = statusMessage != message || statusTone != nextTone + setStatus(message, tone: nextTone) + + if let accessError = error as? DrawerFileAccessError { + hasTransientAccessFailure = accessError.isTransient + if accessError.isTransient { + scheduleProviderRetry() + return + } + } else { + hasTransientAccessFailure = false + } + + cancelProviderRetry() + if changed { + DrawerHaptics.shared.error() + } + } + + private func tone(for error: Error) -> StatusTone { + guard let accessError = error as? DrawerFileAccessError else { return .error } + switch accessError { + case .waitingForICloud, .providerUnavailable: + return .info + case .authenticationRequired, .iCloudConflict: + return .warning + case .itemMissing, .permissionDenied, .notRegularFile, .readFailed, .writeFailed: + return .error + } + } + + private func setStatus(_ message: String?, tone: StatusTone) { + statusMessage = message + statusTone = message == nil ? .info : tone + } + + private func clearStatus() { + setStatus(nil, tone: .info) + } + + private func restorePendingStatus() { + setStatus(pendingStatusMessage, tone: pendingStatusTone) } } diff --git a/iOS/DrawerMobile/Resources/Info.plist b/iOS/DrawerMobile/Resources/Info.plist index 1d960a9..6919415 100644 --- a/iOS/DrawerMobile/Resources/Info.plist +++ b/iOS/DrawerMobile/Resources/Info.plist @@ -35,6 +35,8 @@ LSApplicationCategoryType public.app-category.productivity + NSSupportsLiveActivities + UILaunchScreen UISupportedInterfaceOrientations diff --git a/iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy b/iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy index 9ff19e6..6a9145c 100644 --- a/iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy +++ b/iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy @@ -15,8 +15,11 @@ NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons - + CA92.1 + 1C8F.1 @@ -24,10 +27,8 @@ NSPrivacyAccessedAPICategoryFileTimestamp NSPrivacyAccessedAPITypeReasons - + C617.1 3B52.1 diff --git a/iOS/DrawerMobile/Views/DrawerConnectionView.swift b/iOS/DrawerMobile/Views/DrawerConnectionView.swift index dc9ba39..ef91209 100644 --- a/iOS/DrawerMobile/Views/DrawerConnectionView.swift +++ b/iOS/DrawerMobile/Views/DrawerConnectionView.swift @@ -2,6 +2,7 @@ import SwiftUI struct DrawerConnectionView: View { let needsPermission: Bool + let waitingForProvider: Bool let message: String? let chooseFile: () -> Void @@ -17,14 +18,12 @@ struct DrawerConnectionView: View { .shadow(color: .black.opacity(0.10), radius: 24, y: 12) .padding(.bottom, 30) - Text(needsPermission ? "Reconnect your drawer." : "Your day is already a file.") + Text(title) .font(.system(.largeTitle, design: .rounded, weight: .bold)) .multilineTextAlignment(.center) .tracking(-0.8) - Text(needsPermission - ? "iOS lost access to the file. Choose the same Drawer.md again and everything picks up where it left off." - : "Choose the Drawer.md you already use on your Mac or in Obsidian. Drawer reads it in place — no account, import, or second database.") + Text(explanation) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -32,17 +31,27 @@ struct DrawerConnectionView: View { .padding(.top, 14) .padding(.horizontal, 12) + if waitingForProvider { + ProgressView() + .controlSize(.regular) + .padding(.top, 22) + .accessibilityLabel("Waiting for Drawer.md to become available") + } + if let message, !message.isEmpty { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 18) + Label( + message, + systemImage: waitingForProvider ? "icloud.and.arrow.down" : "exclamationmark.triangle.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 18) } Button(action: chooseFile) { HStack(spacing: 10) { Image(systemName: "doc.badge.plus") - Text(needsPermission ? "Choose Drawer.md Again" : "Choose Drawer.md") + Text(buttonTitle) .fontWeight(.semibold) } .frame(maxWidth: .infinity) @@ -53,7 +62,7 @@ struct DrawerConnectionView: View { .buttonStyle(TactileButtonStyle(pressedScale: 0.985)) .padding(.top, 30) - Text("Markdown stays canonical. Drawer only gives it a faster surface.") + Text("Markdown stays canonical. Local or cloud, Drawer never replaces it with a private task database.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) @@ -65,4 +74,26 @@ struct DrawerConnectionView: View { .frame(maxWidth: 520) .frame(maxWidth: .infinity) } + + private var title: String { + if waitingForProvider { return "Getting Drawer.md ready." } + if needsPermission { return "Reconnect your drawer." } + return "Your day is already a file." + } + + private var explanation: String { + if waitingForProvider { + return "Files has granted access, but the selected cloud file isn't current on this iPhone yet. Drawer is bringing down the canonical copy and will open it automatically when it's safe to edit." + } + if needsPermission { + return "iOS lost access to the file. Choose the same Drawer.md again and everything picks up where it left off." + } + return "Choose Drawer.md from On My iPhone, iCloud Drive, or the Files location your vault uses. Drawer edits that file in place — no account, import, or second database." + } + + private var buttonTitle: String { + if waitingForProvider { return "Choose a Different Drawer.md" } + if needsPermission { return "Choose Drawer.md Again" } + return "Choose Drawer.md" + } } diff --git a/iOS/DrawerMobile/Views/DrawerHomeView.swift b/iOS/DrawerMobile/Views/DrawerHomeView.swift index 37ce9a5..6db89ac 100644 --- a/iOS/DrawerMobile/Views/DrawerHomeView.swift +++ b/iOS/DrawerMobile/Views/DrawerHomeView.swift @@ -29,7 +29,7 @@ struct DrawerHomeView: View { } if let status = model.statusMessage, !status.isEmpty { - statusBanner(status) + statusBanner(status, tone: model.statusTone) } if !visibleCarried.isEmpty { @@ -38,7 +38,7 @@ struct DrawerHomeView: View { todaySection - if !model.upcomingItems.isEmpty { + if !visibleUpcoming.isEmpty { collapsibleSection( title: model.upcomingLabel.isEmpty ? "Next" : model.upcomingLabel, count: visibleUpcoming.count, @@ -47,7 +47,7 @@ struct DrawerHomeView: View { ) } - if !model.backlogItems.isEmpty { + if !visibleBacklog.isEmpty { collapsibleSection(title: "Backlog", count: visibleBacklog.count, isExpanded: $showBacklog, items: visibleBacklog) } @@ -72,7 +72,7 @@ struct DrawerHomeView: View { } label: { Image(systemName: "ellipsis") .font(.system(size: 17, weight: .semibold)) - .frame(width: 36, height: 36) + .frame(width: 44, height: 44) } .accessibilityLabel("Drawer options") } @@ -92,30 +92,55 @@ struct DrawerHomeView: View { } private var dayHeader: some View { - HStack(alignment: .bottom, spacing: 16) { - VStack(alignment: .leading, spacing: 3) { - Text(Date.now.formatted(.dateTime.weekday(.wide))) - .font(.system(size: 34, weight: .bold, design: .rounded)) - .tracking(-1.1) - Text(Date.now.formatted(.dateTime.month(.wide).day())) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) + ViewThatFits(in: .horizontal) { + HStack(alignment: .bottom, spacing: 16) { + dayIdentity + Spacer(minLength: 10) + remainingBadge } - Spacer(minLength: 10) - VStack(alignment: .trailing, spacing: 2) { - Text("\(model.remainingCount)") - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .monospacedDigit() - Text("left").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + dayIdentity + HStack(spacing: 5) { + Text("\(model.remainingCount)") + .font(.system(.title2, design: .rounded, weight: .semibold)) + .monospacedDigit() + Text("left") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(model.remainingCount) tasks remaining") } - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(model.remainingCount) tasks remaining") } .padding(.horizontal, 4) .padding(.top, 8) .padding(.bottom, 2) } + private var dayIdentity: some View { + VStack(alignment: .leading, spacing: 3) { + Text(Date.now.formatted(.dateTime.weekday(.wide))) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .tracking(-0.8) + .fixedSize(horizontal: false, vertical: true) + Text(Date.now.formatted(.dateTime.month(.wide).day())) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + } + + private var remainingBadge: some View { + VStack(alignment: .trailing, spacing: 2) { + Text("\(model.remainingCount)") + .font(.system(.title, design: .rounded, weight: .semibold)) + .monospacedDigit() + Text("left").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(model.remainingCount) tasks remaining") + } + private var todaySection: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { @@ -147,6 +172,8 @@ struct DrawerHomeView: View { } label: { Label("Start", systemImage: "play.fill") .font(.caption.weight(.bold)) + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.borderless) .accessibilityLabel("Start \(routine.title) routine") @@ -154,7 +181,7 @@ struct DrawerHomeView: View { } .padding(.horizontal, 4) - TaskTray(items: filtered(routine.items), model: model) { selectedTask = $0 } + TaskTray(items: routine.items, model: model) { selectedTask = $0 } } } } @@ -163,7 +190,7 @@ struct DrawerHomeView: View { private var todayRoutines: [DrawerRoutine] { var order: [String] = [] var buckets: [String: [TodoItem]] = [:] - for item in model.todayItems { + for item in visibleToday { guard let title = item.subsection, !title.isEmpty else { continue } if buckets[title] == nil { order.append(title) } buckets[title, default: []].append(item) @@ -209,9 +236,10 @@ struct DrawerHomeView: View { .foregroundStyle(.secondary) .contentShape(Rectangle()) .padding(.horizontal, 4) - .frame(minHeight: 36) + .frame(minHeight: 44) } .buttonStyle(TactileButtonStyle(pressedScale: 0.99)) + .accessibilityValue(isExpanded.wrappedValue ? "Expanded" : "Collapsed") if isExpanded.wrappedValue { TaskTray(items: items, model: model) { selectedTask = $0 } @@ -220,15 +248,48 @@ struct DrawerHomeView: View { } } - @ViewBuilder - private func statusBanner(_ status: String) -> some View { + private func statusBanner(_ status: String, tone: DrawerMobileModel.StatusTone) -> some View { HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) - Text(status).font(.footnote).foregroundStyle(.secondary) + Image(systemName: statusIcon(tone)) + .foregroundStyle(statusStyle(tone)) + Text(status) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(13) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(statusStyle(tone).opacity(0.15), lineWidth: 0.75) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(statusAccessibilityPrefix(tone) + status) + } + + private func statusIcon(_ tone: DrawerMobileModel.StatusTone) -> String { + switch tone { + case .info: "arrow.triangle.2.circlepath" + case .warning: "exclamationmark.triangle.fill" + case .error: "xmark.octagon.fill" + } + } + + private func statusStyle(_ tone: DrawerMobileModel.StatusTone) -> Color { + switch tone { + case .info: .accentColor + case .warning: .orange + case .error: .red + } + } + + private func statusAccessibilityPrefix(_ tone: DrawerMobileModel.StatusTone) -> String { + switch tone { + case .info: "Status. " + case .warning: "Attention. " + case .error: "Error. " + } } private func filtered(_ items: [TodoItem]) -> [TodoItem] { @@ -264,8 +325,9 @@ private struct DrawerRoutineSession: View { VStack(spacing: 7) { Text(title) - .font(.system(size: 30, weight: .bold, design: .rounded)) - .tracking(-0.8) + .font(.system(.title, design: .rounded, weight: .bold)) + .tracking(-0.5) + .multilineTextAlignment(.center) Text("\(completedCount) of \(allItems.count)") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) @@ -279,9 +341,10 @@ private struct DrawerRoutineSession: View { Image(systemName: current.isInProgress ? "circle.lefthalf.filled" : "circle") .font(.system(size: 30, weight: .medium)) .foregroundStyle(.tint) + .accessibilityHidden(true) Text(current.title) - .font(.system(size: 30, weight: .bold, design: .rounded)) + .font(.system(.title, design: .rounded, weight: .bold)) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -292,37 +355,15 @@ private struct DrawerRoutineSession: View { .monospacedDigit() } - HStack(spacing: 12) { - Button { - model.startFocus(on: current) - DrawerHaptics.shared.focusStarted() - } label: { - Label("Focus", systemImage: "timer") - .font(.headline) - .frame(maxWidth: .infinity) - .frame(height: 52) + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + routineFocusButton(current) + routineDoneButton(current) } - .buttonStyle(.bordered) - .buttonBorderShape(.roundedRectangle(radius: 16)) - - Button { - if model.toggle(current) { - DrawerHaptics.shared.taskCompleted() - if remaining.count == 1 { - DrawerHaptics.shared.groupFinished() - DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0.12 : 0.35)) { - dismiss() - } - } - } - } label: { - Label("Done", systemImage: "checkmark") - .font(.headline) - .frame(maxWidth: .infinity) - .frame(height: 52) + VStack(spacing: 10) { + routineFocusButton(current) + routineDoneButton(current) } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 16)) } } .padding(.horizontal, 28) @@ -336,6 +377,8 @@ private struct DrawerRoutineSession: View { Text("Done") .font(.title2.bold()) } + .accessibilityElement(children: .combine) + .accessibilityLabel("Routine complete") } Spacer() @@ -358,6 +401,45 @@ private struct DrawerRoutineSession: View { } } } + + private func routineFocusButton(_ item: TodoItem) -> some View { + Button { + model.startFocus(on: item) + DrawerHaptics.shared.focusStarted() + } label: { + Label("Focus", systemImage: "timer") + .font(.headline) + .frame(maxWidth: .infinity) + .frame(minHeight: 52) + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } + + private func routineDoneButton(_ item: TodoItem) -> some View { + Button { + if model.toggle(item) { + DrawerHaptics.shared.taskCompleted() + DrawerActionFeedbackCenter.success( + "Completed \(item.title)", + systemImage: "checkmark.circle.fill" + ) + if remaining.count == 1 { + DrawerHaptics.shared.groupFinished() + DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0.12 : 0.35)) { + dismiss() + } + } + } + } label: { + Label("Done", systemImage: "checkmark") + .font(.headline) + .frame(maxWidth: .infinity) + .frame(minHeight: 52) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } } private struct TaskTray: View { @@ -376,6 +458,7 @@ private struct TaskTray: View { .foregroundStyle(.tertiary) .padding(.horizontal, 17) .frame(minHeight: 58) + .accessibilityElement(children: .combine) } else { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in MobileTaskRow(model: model, item: item) { openTask(item) } diff --git a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift index 620664a..b7338dc 100644 --- a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift +++ b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift @@ -16,6 +16,7 @@ struct DrawerTaskDetailSheet: View { @State private var savedNote: String @State private var noteFeedback: NoteFeedback? @State private var recurrence: TodoRecurrence? + @State private var showingEditor = false @FocusState private var noteFocused: Bool init(model: DrawerMobileModel, item: TodoItem) { @@ -55,6 +56,21 @@ struct DrawerTaskDetailSheet: View { .onChange(of: noteDraft) { _, _ in if noteDraft != savedNote { noteFeedback = nil } } + .sheet(isPresented: $showingEditor) { + DrawerTaskEditSheet( + model: model, + item: item, + initialNote: noteDraft + ) { + // Editing the title or duration changes TodoItem's raw-line + // identity. Close this stale detail surface immediately after + // the single canonical transaction succeeds. + dismiss() + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(30) + } } private var taskHeader: some View { @@ -80,10 +96,25 @@ struct DrawerTaskDetailSheet: View { } } - Text(item.title) - .font(.system(size: 27, weight: .bold, design: .rounded)) - .tracking(-0.5) - .fixedSize(horizontal: false, vertical: true) + HStack(alignment: .top, spacing: 10) { + Text(item.title) + .font(.system(.title2, design: .rounded, weight: .bold)) + .tracking(-0.3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + showingEditor = true + } label: { + Image(systemName: "pencil") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .background(.quaternary.opacity(0.55), in: Circle()) + } + .buttonStyle(TactileButtonStyle(pressedScale: 0.92)) + .accessibilityLabel("Edit task") + } if recurrence != nil, item.minutes != 25 { Label("\(item.minutes)m", systemImage: "timer") @@ -102,7 +133,7 @@ struct DrawerTaskDetailSheet: View { Label("Focus for \(item.minutes) min", systemImage: "timer") .font(.headline) .frame(maxWidth: .infinity) - .frame(height: 52) + .frame(minHeight: 52) .foregroundStyle(.white) .background(.tint, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } @@ -128,6 +159,7 @@ struct DrawerTaskDetailSheet: View { } Button(noteFeedback == .failed ? "Retry" : "Save") { saveNote() } .font(.subheadline.weight(.semibold)) + .frame(minHeight: 44) } else if noteFeedback == .saved { Label("Saved", systemImage: "checkmark.circle.fill") .font(.caption.weight(.semibold)) @@ -152,6 +184,7 @@ struct DrawerTaskDetailSheet: View { .allowsHitTesting(false) } } + .accessibilityLabel("Task note") } } @@ -188,7 +221,7 @@ struct DrawerTaskDetailSheet: View { } .foregroundStyle(.primary) .padding(.horizontal, 14) - .frame(height: 54) + .frame(minHeight: 54) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } .buttonStyle(TactileButtonStyle(pressedScale: 0.99)) @@ -254,6 +287,7 @@ struct DrawerTaskDetailSheet: View { .foregroundStyle(.tertiary) } .padding(14) + .frame(minHeight: 50) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } .buttonStyle(TactileButtonStyle(pressedScale: 0.99)) @@ -277,7 +311,7 @@ struct DrawerTaskDetailSheet: View { Label("Delete Task", systemImage: "trash") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) - .frame(height: 48) + .frame(minHeight: 50) .background(Color.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) } .buttonStyle(TactileButtonStyle(pressedScale: 0.99)) @@ -294,7 +328,7 @@ struct DrawerTaskDetailSheet: View { } .foregroundStyle(.primary) .padding(.horizontal, 14) - .frame(height: 50) + .frame(minHeight: 50) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } @@ -351,3 +385,167 @@ struct DrawerTaskDetailSheet: View { } } } + +private struct DrawerTaskEditSheet: View { + @ObservedObject var model: DrawerMobileModel + let item: TodoItem + let onSaved: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var title: String + @State private var minutes: Int + @State private var note: String + @FocusState private var titleFocused: Bool + + private let durationChoices = [15, 25, 30, 45, 60, 90, 120] + + init( + model: DrawerMobileModel, + item: TodoItem, + initialNote: String, + onSaved: @escaping () -> Void + ) { + self.model = model + self.item = item + self.onSaved = onSaved + _title = State(initialValue: item.title) + _minutes = State(initialValue: item.minutes) + _note = State(initialValue: initialNote) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + editSection("TITLE") { + TextField("Task title", text: $title, axis: .vertical) + .focused($titleFocused) + .font(.title3.weight(.semibold)) + .textInputAutocapitalization(.sentences) + .submitLabel(.done) + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityLabel("Task title") + } + + editSection("FOCUS LENGTH") { + Menu { + ForEach(durationChoices, id: \.self) { choice in + Button { + minutes = choice + DrawerHaptics.shared.progressChanged() + } label: { + if minutes == choice { + Label("\(choice) minutes", systemImage: "checkmark") + } else { + Text("\(choice) minutes") + } + } + } + } label: { + HStack(spacing: 12) { + Image(systemName: "timer") + .frame(width: 22) + Text("\(minutes) min") + .font(.headline) + .monospacedDigit() + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .frame(minHeight: 54) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(TactileButtonStyle(pressedScale: 0.99)) + .accessibilityLabel("Focus length, \(minutes) minutes") + + if !durationChoices.contains(minutes) { + Text("Keeping the custom \(minutes)-minute length from Drawer.md. Choose a preset to change it.") + .font(.caption) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + editSection("NOTE") { + TextEditor(text: $note) + .font(.body) + .scrollContentBackground(.hidden) + .frame(minHeight: 150) + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityLabel("Task note") + } + + HStack(spacing: 8) { + Image(systemName: "doc.text") + Text("Save writes this edit directly to \(model.sourceName) in one transaction.") + } + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 36) + } + .navigationTitle("Edit Task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .fontWeight(.semibold) + .disabled(!canSave) + } + } + } + .interactiveDismissDisabled(hasChanges) + } + + private var cleanTitle: String { + title.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var cleanNote: String { + note.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var hasChanges: Bool { + cleanTitle != item.title || + minutes != item.minutes || + cleanNote != (item.note ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSave: Bool { + !cleanTitle.isEmpty && (1...480).contains(minutes) && hasChanges + } + + @ViewBuilder + private func editSection( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.caption.weight(.bold)) + .tracking(0.7) + .foregroundStyle(.secondary) + content() + } + } + + private func save() { + guard canSave else { return } + if model.updateTask(item, title: cleanTitle, minutes: minutes, note: note) { + DrawerHaptics.shared.saved() + DrawerActionFeedbackCenter.success("Task updated", systemImage: "checkmark.circle.fill") + dismiss() + DispatchQueue.main.async(execute: onSaved) + } + } +} diff --git a/iOS/DrawerMobile/Views/FocusStrip.swift b/iOS/DrawerMobile/Views/FocusStrip.swift index dd1418e..ae37055 100644 --- a/iOS/DrawerMobile/Views/FocusStrip.swift +++ b/iOS/DrawerMobile/Views/FocusStrip.swift @@ -20,6 +20,7 @@ struct FocusStrip: View { .font(.system(size: 17, weight: .bold)) .foregroundStyle(.tint) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text(timer.phase == .finished ? "Focus complete" : timer.taskTitle) @@ -35,13 +36,16 @@ struct FocusStrip: View { Spacer(minLength: 8) if timer.phase == .finished { - Button("Done") { + Button("Close") { model.resetFocus() DrawerHaptics.shared.focusDismissed() + DrawerActionFeedbackCenter.announce("Focus session closed") } .font(.subheadline.weight(.bold)) .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) + .frame(minHeight: 44) + .accessibilityHint("Closes the timer without changing the task") } else { Button { switch timer.phase { @@ -57,23 +61,27 @@ struct FocusStrip: View { } label: { Image(systemName: timer.phase == .running ? "pause.fill" : "play.fill") .font(.system(size: 14, weight: .bold)) - .frame(width: 36, height: 36) + .frame(width: 44, height: 44) .background(.quaternary.opacity(0.6), in: Circle()) + .contentShape(Circle()) } - .buttonStyle(TactileButtonStyle(pressedScale: 0.91)) + .buttonStyle(TactileButtonStyle(pressedScale: 0.91, pressedOpacity: 0.96)) .accessibilityLabel(timer.phase == .running ? "Pause focus" : "Resume focus") Button { model.resetFocus() - DrawerHaptics.shared.progressChanged() + DrawerHaptics.shared.focusDismissed() + DrawerActionFeedbackCenter.announce("Focus ended") } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.secondary) - .frame(width: 32, height: 32) + .frame(width: 44, height: 44) + .contentShape(Circle()) } - .buttonStyle(TactileButtonStyle(pressedScale: 0.91)) + .buttonStyle(TactileButtonStyle(pressedScale: 0.91, pressedOpacity: 0.96)) .accessibilityLabel("End focus") + .accessibilityHint("Stops the timer without changing the task") } } .padding(.horizontal, 13) diff --git a/iOS/DrawerMobile/Views/MobileTaskRow.swift b/iOS/DrawerMobile/Views/MobileTaskRow.swift index 5d038a3..f84fea1 100644 --- a/iOS/DrawerMobile/Views/MobileTaskRow.swift +++ b/iOS/DrawerMobile/Views/MobileTaskRow.swift @@ -13,9 +13,9 @@ struct MobileTaskRow: View { @State private var checkboxScale: CGFloat = 1 private enum DragAxis { case horizontal, vertical } - private enum ArmedAction { case none, progress, delete } + private enum ArmedAction { case none, primary, delete } - private let progressThreshold: CGFloat = 72 + private let primaryThreshold: CGFloat = 72 private let deleteThreshold: CGFloat = 108 var body: some View { @@ -27,11 +27,8 @@ struct MobileTaskRow: View { .contentShape(Rectangle()) .gesture(swipeGesture, including: .all) .contextMenu { contextMenu } - .accessibilityAction(named: item.isInProgress ? "Clear in progress" : "Mark in progress") { - if model.setInProgress(item, !item.isInProgress) { - DrawerHaptics.shared.progressChanged() - confirmProgressChange() - } + .accessibilityAction(named: primaryAccessibilityAction) { + performPrimaryAction() } .accessibilityAction(named: "Delete") { if model.delete(item) { DrawerHaptics.shared.deleted() } @@ -89,7 +86,7 @@ struct MobileTaskRow: View { .background { ZStack(alignment: .leading) { Color(uiColor: .secondarySystemGroupedBackground).opacity(0.82) - if item.isInProgress { + if item.isInProgress && !item.isDone { Color.accentColor.opacity(0.075) Rectangle() .fill(.tint) @@ -119,7 +116,7 @@ struct MobileTaskRow: View { .scaleEffect(checkboxScale) } .buttonStyle(.plain) - .accessibilityLabel(item.isDone ? "Mark incomplete" : "Complete task") + .accessibilityLabel(item.isDone ? "Reopen task" : "Complete task") .accessibilityValue(item.title) } @@ -129,12 +126,17 @@ struct MobileTaskRow: View { return "circle" } + private var primaryAccessibilityAction: String { + if item.isDone { return "Reopen" } + return item.isInProgress ? "Clear in progress" : "Mark in progress" + } + private var swipeReveals: some View { HStack(spacing: 0) { HStack(spacing: 8) { - Image(systemName: item.isInProgress ? "circle" : "circle.lefthalf.filled") + Image(systemName: leadingActionIcon) .font(.system(size: 17, weight: .bold)) - Text(item.isInProgress ? "Clear" : "Doing") + Text(leadingActionTitle) .font(.caption.weight(.bold)) } .foregroundStyle(.white) @@ -156,6 +158,16 @@ struct MobileTaskRow: View { .accessibilityHidden(true) } + private var leadingActionTitle: String { + if item.isDone { return "Reopen" } + return item.isInProgress ? "Clear" : "Doing" + } + + private var leadingActionIcon: String { + if item.isDone { return "arrow.uturn.backward" } + return item.isInProgress ? "circle" : "circle.lefthalf.filled" + } + private var swipeGesture: some Gesture { DragGesture(minimumDistance: 12, coordinateSpace: .local) .onChanged { value in @@ -171,7 +183,7 @@ struct MobileTaskRow: View { dragOffset = resisted(value.translation.width) let next = armedAction(for: dragOffset) if next != armedAction { - if next == .progress { + if next == .primary { DrawerHaptics.shared.swipeThreshold() } else if next == .delete { DrawerHaptics.shared.destructiveArmed() @@ -186,7 +198,7 @@ struct MobileTaskRow: View { if reduceMotion { dragOffset = 0 } else { - withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.84)) { dragOffset = 0 } } @@ -195,36 +207,37 @@ struct MobileTaskRow: View { let projected = value.predictedEndTranslation.width let commitsDelete = dragOffset <= -deleteThreshold || projected <= -180 - let commitsProgress = dragOffset >= progressThreshold || projected >= 145 + let commitsPrimary = dragOffset >= primaryThreshold || projected >= 145 if commitsDelete { if model.delete(item) { DrawerHaptics.shared.deleted() } - } else if commitsProgress { - if model.setInProgress(item, !item.isInProgress) { - DrawerHaptics.shared.progressChanged() - confirmProgressChange() - } + } else if commitsPrimary { + performPrimaryAction() } } } @ViewBuilder private var contextMenu: some View { - Button(item.isInProgress ? "Clear In Progress" : "Mark In Progress", systemImage: "circle.lefthalf.filled") { - if model.setInProgress(item, !item.isInProgress) { - DrawerHaptics.shared.progressChanged() - confirmProgressChange() + if item.isDone { + Button("Reopen", systemImage: "arrow.uturn.backward") { + reopenTask() + } + } else { + Button(item.isInProgress ? "Clear In Progress" : "Mark In Progress", systemImage: "circle.lefthalf.filled") { + changeProgress() + } + Button("Start Focus", systemImage: "timer") { + model.startFocus(on: item) + DrawerHaptics.shared.focusStarted() + } + Menu("Move", systemImage: "arrow.turn.down.right") { + moveButton(.today) + moveButton(.tomorrow) + moveButton(.backlog) } } - Button("Start Focus", systemImage: "timer") { - model.startFocus(on: item) - DrawerHaptics.shared.focusStarted() - } - Menu("Move", systemImage: "arrow.turn.down.right") { - moveButton(.today) - moveButton(.tomorrow) - moveButton(.backlog) - } + Divider() Button("Delete", systemImage: "trash", role: .destructive) { if model.delete(item) { DrawerHaptics.shared.deleted() } @@ -239,10 +252,35 @@ struct MobileTaskRow: View { } } + private func performPrimaryAction() { + if item.isDone { + reopenTask() + } else { + changeProgress() + } + } + + private func changeProgress() { + if model.setInProgress(item, !item.isInProgress) { + DrawerHaptics.shared.progressChanged() + confirmProgressChange() + } + } + + private func reopenTask() { + if model.toggle(item) { + DrawerHaptics.shared.taskReopened() + DrawerActionFeedbackCenter.success( + "Reopened \(item.title)", + systemImage: "arrow.uturn.backward.circle.fill" + ) + } + } + private func checkboxTapped() { let willComplete = !item.isDone if !reduceMotion { - withAnimation(.spring(response: 0.11, dampingFraction: 0.68)) { + withAnimation(.spring(response: 0.10, dampingFraction: 0.72)) { checkboxScale = 0.78 } } @@ -265,7 +303,7 @@ struct MobileTaskRow: View { } } if !reduceMotion { - withAnimation(.spring(response: 0.24, dampingFraction: 0.58)) { + withAnimation(.spring(response: 0.22, dampingFraction: 0.64)) { checkboxScale = 1 } } else { @@ -276,7 +314,7 @@ struct MobileTaskRow: View { if reduceMotion { perform() } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.045, execute: perform) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.035, execute: perform) } } @@ -288,7 +326,7 @@ struct MobileTaskRow: View { } private func armedAction(for offset: CGFloat) -> ArmedAction { - if offset >= progressThreshold { return .progress } + if offset >= primaryThreshold { return .primary } if offset <= -deleteThreshold { return .delete } return .none } diff --git a/iOS/DrawerMobile/Views/QuickCaptureBar.swift b/iOS/DrawerMobile/Views/QuickCaptureBar.swift index f8a4c54..61716a3 100644 --- a/iOS/DrawerMobile/Views/QuickCaptureBar.swift +++ b/iOS/DrawerMobile/Views/QuickCaptureBar.swift @@ -4,11 +4,16 @@ struct QuickCaptureBar: View { @ObservedObject var model: DrawerMobileModel @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var text = "" - @State private var destination: DrawerTaskDestination = .today + @SceneStorage("drawer.capture.draft.v1") private var text = "" + @SceneStorage("drawer.capture.destination.v1") private var destinationRawValue = DrawerTaskDestination.today.rawValue + @State private var handledCaptureToken = 0 @State private var actionFeedback: DrawerActionFeedbackPayload? @FocusState private var focused: Bool + private var destination: DrawerTaskDestination { + DrawerTaskDestination(rawValue: destinationRawValue) ?? .today + } + var body: some View { VStack(spacing: 8) { if let undoLabel = model.undoLabel { @@ -23,7 +28,7 @@ struct QuickCaptureBar: View { Menu { ForEach(DrawerTaskDestination.allCases, id: \.self) { choice in Button { - destination = choice + destinationRawValue = choice.rawValue DrawerHaptics.shared.progressChanged() } label: { Label(choice.title, systemImage: choice == destination ? "checkmark" : destinationIcon(choice)) @@ -33,7 +38,7 @@ struct QuickCaptureBar: View { Image(systemName: destinationIcon(destination)) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.secondary) - .frame(width: 42, height: 42) + .frame(width: 44, height: 44) .background(.quaternary.opacity(0.5), in: Circle()) .contentShape(Circle()) } @@ -52,34 +57,36 @@ struct QuickCaptureBar: View { Image(systemName: "arrow.up") .font(.system(size: 15, weight: .bold)) .foregroundStyle(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.white)) - .frame(width: 38, height: 38) + .frame(width: 44, height: 44) .background( text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? AnyShapeStyle(.quaternary.opacity(0.7)) : AnyShapeStyle(Color.accentColor), in: Circle() ) + .contentShape(Circle()) } - .buttonStyle(TactileButtonStyle(pressedScale: 0.92)) + .buttonStyle(TactileButtonStyle(pressedScale: 0.92, pressedOpacity: 0.96)) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityLabel("Add task") } .padding(.leading, 8) .padding(.trailing, 8) .padding(.vertical, 7) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 25, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.primary.opacity(focused ? 0.12 : 0.055), lineWidth: focused ? 1 : 0.75) + RoundedRectangle(cornerRadius: 25, style: .continuous) + .stroke(.primary.opacity(focused ? 0.13 : 0.055), lineWidth: focused ? 1 : 0.75) } - .shadow(color: .black.opacity(0.10), radius: 20, y: 8) + .shadow(color: .black.opacity(focused ? 0.12 : 0.08), radius: focused ? 22 : 16, y: focused ? 9 : 7) } .padding(.horizontal, 12) .padding(.top, 7) .padding(.bottom, 8) .background(.clear) - .onChange(of: model.captureRequestToken) { _, _ in - focused = true + .onAppear { handleCaptureRequest(model.captureRequestToken) } + .onChange(of: model.captureRequestToken) { _, token in + handleCaptureRequest(token) } .onReceive(NotificationCenter.default.publisher(for: .drawerActionFeedback)) { notification in guard let payload = notification.object as? DrawerActionFeedbackPayload else { return } @@ -90,6 +97,7 @@ struct QuickCaptureBar: View { } } } + .animation(reduceMotion ? nil : .snappy(duration: 0.20), value: focused) .animation(reduceMotion ? nil : .snappy(duration: 0.22), value: model.undoLabel) .animation(reduceMotion ? nil : .snappy(duration: 0.22), value: actionFeedback?.id) } @@ -98,7 +106,7 @@ struct QuickCaptureBar: View { HStack(spacing: 10) { Text(label) .font(.footnote.weight(.medium)) - .lineLimit(1) + .lineLimit(2) Spacer(minLength: 8) Button("Undo") { if model.undoLastMutation() { @@ -107,9 +115,11 @@ struct QuickCaptureBar: View { } } .font(.footnote.weight(.bold)) + .frame(minHeight: 44) } .padding(.horizontal, 15) - .frame(height: 44) + .padding(.vertical, 4) + .frame(minHeight: 44) .foregroundStyle(.primary) .background(.regularMaterial, in: Capsule()) .overlay { Capsule().stroke(.primary.opacity(0.06), lineWidth: 0.75) } @@ -124,11 +134,12 @@ struct QuickCaptureBar: View { .foregroundStyle(.tint) Text(feedback.message) .font(.footnote.weight(.semibold)) - .lineLimit(1) + .lineLimit(2) Spacer(minLength: 0) } .padding(.horizontal, 15) - .frame(height: 44) + .padding(.vertical, 10) + .frame(minHeight: 44) .foregroundStyle(.primary) .background(.regularMaterial, in: Capsule()) .overlay { Capsule().stroke(.primary.opacity(0.06), lineWidth: 0.75) } @@ -137,9 +148,16 @@ struct QuickCaptureBar: View { .accessibilityElement(children: .combine) } + private func handleCaptureRequest(_ token: Int) { + guard token > 0, token != handledCaptureToken else { return } + handledCaptureToken = token + DispatchQueue.main.async { focused = true } + } + private func save() { let clean = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !clean.isEmpty else { return } + if model.add(clean, destination: destination) { text = "" DrawerHaptics.shared.taskAdded() @@ -147,13 +165,16 @@ struct QuickCaptureBar: View { "Added to \(destination.title)", systemImage: "plus.circle.fill" ) + if !reduceMotion { - withAnimation(.spring(response: 0.24, dampingFraction: 0.65)) { + withAnimation(.spring(response: 0.22, dampingFraction: 0.72)) { focused = false } } else { focused = false } + } else { + focused = true } } diff --git a/iOS/DrawerWidgets/DrawerWidget.swift b/iOS/DrawerWidgets/DrawerWidget.swift index b42243a..11874ec 100644 --- a/iOS/DrawerWidgets/DrawerWidget.swift +++ b/iOS/DrawerWidgets/DrawerWidget.swift @@ -1,3 +1,4 @@ +import ActivityKit import AppIntents import SwiftUI import UIKit @@ -23,16 +24,40 @@ struct DrawerWidgetProvider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let feedback = WidgetInteractionFeedbackStore.current(now: now) let entry = DrawerWidgetEntry( - date: Date(), + date: now, snapshot: WidgetSnapshotStore.current(), - interactionFeedback: WidgetInteractionFeedbackStore.current() + interactionFeedback: feedback ) completion(Timeline( entries: [entry], - policy: .after(Date().addingTimeInterval(15 * 60)) + policy: .after(nextRefreshDate(after: now, feedback: feedback)) )) } + + private func nextRefreshDate( + after now: Date, + feedback: WidgetInteractionFeedback? + ) -> Date { + var candidates = [now.addingTimeInterval(15 * 60)] + let calendar = Calendar.current + + if let nextDay = calendar.date( + byAdding: .day, + value: 1, + to: calendar.startOfDay(for: now) + ) { + candidates.append(nextDay.addingTimeInterval(1)) + } + + if let feedback { + candidates.append(feedback.occurredAt.addingTimeInterval(5 * 60 + 1)) + } + + return candidates.filter { $0 > now }.min() ?? now.addingTimeInterval(15 * 60) + } } struct DrawerWidget: Widget { @@ -51,6 +76,7 @@ struct DrawerWidget: Widget { .configurationDisplayName("Drawer") .description("Your day, straight from Drawer.md.") .supportedFamilies([ + .systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, @@ -59,6 +85,140 @@ struct DrawerWidget: Widget { } } +struct DrawerFocusLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: DrawerFocusActivityAttributes.self) { context in + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.14)) + .frame(width: 42, height: 42) + Image(systemName: focusSymbol(context)) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.tint) + } + + VStack(alignment: .leading, spacing: 3) { + Text(focusTitle(context)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(context.attributes.taskTitle) + .font(.headline.weight(.semibold)) + .lineLimit(1) + .privacySensitive() + } + + Spacer(minLength: 8) + + FocusLiveTime(context: context) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + } + .padding(.horizontal, 4) + .activityBackgroundTint(Color(uiColor: .secondarySystemBackground)) + .activitySystemActionForegroundColor(.primary) + .widgetURL(URL(string: "drawer://today")) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label("Focus", systemImage: focusSymbol(context)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + DynamicIslandExpandedRegion(.trailing) { + FocusLiveTime(context: context) + .font(.system(.headline, design: .rounded, weight: .bold)) + .monospacedDigit() + } + + DynamicIslandExpandedRegion(.bottom) { + Text(context.attributes.taskTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + .privacySensitive() + .frame(maxWidth: .infinity, alignment: .leading) + } + } compactLeading: { + Image(systemName: focusSymbol(context)) + .foregroundStyle(.tint) + } compactTrailing: { + FocusLiveTime(context: context) + .font(.caption2.weight(.bold)) + .monospacedDigit() + .frame(maxWidth: 48) + } minimal: { + Image(systemName: focusSymbol(context)) + .foregroundStyle(.tint) + } + .widgetURL(URL(string: "drawer://today")) + } + } + + private func focusTitle( + _ context: ActivityViewContext + ) -> String { + switch effectivePhase(context) { + case .running: "Focus" + case .paused: "Focus paused" + case .finished: "Focus complete" + case .ended: "Focus ended" + } + } + + private func focusSymbol( + _ context: ActivityViewContext + ) -> String { + switch effectivePhase(context) { + case .running: "timer" + case .paused: "pause.fill" + case .finished: "checkmark" + case .ended: "xmark" + } + } + + private func effectivePhase( + _ context: ActivityViewContext + ) -> DrawerFocusActivityAttributes.ContentState.Phase { + if context.isStale, context.state.phase == .running { return .finished } + return context.state.phase + } +} + +private struct FocusLiveTime: View { + let context: ActivityViewContext + + var body: some View { + let phase: DrawerFocusActivityAttributes.ContentState.Phase = + context.isStale && context.state.phase == .running ? .finished : context.state.phase + + switch phase { + case .running: + if let endDate = context.state.endDate { + let now = Date() + if endDate > now { + Text(timerInterval: now...endDate, countsDown: true) + } else { + Text("0:00") + } + } else { + Text(format(context.state.remaining)) + } + case .paused: + Text(format(context.state.remaining)) + case .finished: + Text("Done") + case .ended: + Text("Ended") + } + } + + private func format(_ interval: TimeInterval) -> String { + let seconds = max(0, Int(interval.rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} + private struct DrawerWidgetView: View { @Environment(\.widgetFamily) private var family let snapshot: WidgetSnapshot @@ -66,6 +226,8 @@ private struct DrawerWidgetView: View { var body: some View { switch family { + case .systemSmall: + smallWidget case .accessoryCircular: accessoryCircular case .accessoryRectangular: @@ -77,6 +239,88 @@ private struct DrawerWidgetView: View { } } + private var smallWidget: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text("DRAWER") + .font(.caption2.weight(.heavy)) + .tracking(0.8) + .foregroundStyle(.secondary) + Spacer(minLength: 4) + if interactionFeedback != nil { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2.weight(.bold)) + .foregroundStyle(.orange) + } + if !snapshot.todayKey.isEmpty { + Text("\(snapshot.remaining)") + .font(.caption.weight(.bold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .invalidatableContent() + } + } + + if snapshot.todayKey.isEmpty { + Spacer(minLength: 0) + Image(systemName: "doc.badge.plus") + .font(.title2) + .foregroundStyle(.secondary) + Text("Connect Drawer.md") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(2) + Spacer(minLength: 0) + } else if let next = snapshot.actionableTasks.first { + Text(next.title) + .font(.headline.weight(next.isInProgress ? .semibold : .medium)) + .lineLimit(3) + .privacySensitive() + .invalidatableContent() + + Spacer(minLength: 0) + + HStack(alignment: .center, spacing: 8) { + if next.minutes != 25 { + Text("\(next.minutes)m") + .font(.caption2.weight(.bold)) + .monospacedDigit() + .foregroundStyle(.tertiary) + } else { + Text(next.bucket == .carried ? "Carried" : "Next") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 2) + + Button(intent: ToggleDrawerTaskIntent(task: next)) { + Image(systemName: next.isInProgress ? "circle.lefthalf.filled" : "circle") + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(next.isInProgress ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + .frame(width: 34, height: 34) + .background(.quaternary.opacity(0.55), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Complete \(next.title)") + } + } else { + Spacer(minLength: 0) + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.tint) + Text("You're clear.") + .font(.headline.weight(.semibold)) + Text("Nothing left today") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + } + .widgetURL(URL(string: "drawer://today")) + } + private func homeWidget(maxTasks: Int, large: Bool) -> some View { VStack(alignment: .leading, spacing: large ? 9 : 7) { header diff --git a/iOS/DrawerWidgets/DrawerWidgetsBundle.swift b/iOS/DrawerWidgets/DrawerWidgetsBundle.swift index 61a406e..8225e0a 100644 --- a/iOS/DrawerWidgets/DrawerWidgetsBundle.swift +++ b/iOS/DrawerWidgets/DrawerWidgetsBundle.swift @@ -5,5 +5,6 @@ import WidgetKit struct DrawerWidgetsBundle: WidgetBundle { var body: some Widget { DrawerWidget() + DrawerFocusLiveActivity() } } diff --git a/iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy b/iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy index 9ff19e6..2d7dadf 100644 --- a/iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy +++ b/iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy @@ -15,8 +15,11 @@ NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons - + CA92.1 + 1C8F.1 @@ -24,10 +27,8 @@ NSPrivacyAccessedAPICategoryFileTimestamp NSPrivacyAccessedAPITypeReasons - + C617.1 3B52.1 diff --git a/iOS/README.md b/iOS/README.md index f28cb35..79deb29 100644 --- a/iOS/README.md +++ b/iOS/README.md @@ -30,17 +30,51 @@ The project uses automatic signing but does not hard-code a development team so 1. Tap **Choose Drawer.md**. 2. Pick the same Markdown file used by Drawer on Mac / your Obsidian vault. -3. Drawer proves the selection is readable UTF-8 Markdown before replacing any existing bookmark. -4. Drawer stores the security-scoped bookmark and coordinates reads/writes through `NSFileCoordinator`. -5. The app writes a small, versioned widget snapshot into the App Group container after successful canonical reads/writes. +3. If the selected file is already available, Drawer proves it is readable UTF-8 Markdown before promoting its bookmark to the canonical source. +4. If Files has granted the URL but iCloud/provider bytes are not safely available yet, Drawer stages that bookmark instead. The staged selection survives relaunch and is promoted only after a real current UTF-8 read succeeds. +5. When changing files, the previous canonical bookmark remains untouched during staging, so an unavailable or invalid replacement cannot strand or silently replace the working source. +6. Drawer coordinates reads/writes through `NSFileCoordinator` and writes a small, versioned widget snapshot into the App Group container only after successful canonical reads/writes. No Drawer account, cloud backend, or task database is involved. +## Supported storage locations + +Drawer intentionally uses the system Files document picker instead of assuming an Obsidian-specific path. The selected `Drawer.md` can therefore live wherever iOS exposes an editable file grant. + +### On My iPhone / local Files storage + +A locally stored Obsidian vault or ordinary Markdown folder works through the same security-scoped bookmark and coordinated file access path. Local files have no cloud-freshness dependency: if the bookmark remains valid, Drawer reads and writes the canonical file directly. + +Obsidian Sync is also compatible with this model because Obsidian maintains a local vault copy. Drawer still edits the selected local `Drawer.md`; Obsidian Sync remains responsible for synchronizing that vault. Drawer does not call Obsidian Sync APIs or maintain a second copy. + +### iCloud Drive / Obsidian vaults + +For Obsidian on iOS, iCloud vaults should live under `iCloud Drive/Obsidian/`. iOS may evict an iCloud file or retain a local copy that is older than the cloud version. + +Drawer checks Apple's iCloud download state before every canonical read/write: + +- **current** — safe to read/write +- **downloaded but stale** — request the newest cloud version and do not mutate yet +- **not downloaded** — request materialization and do not mutate yet +- **unresolved iCloud conflict** — fail closed until the conflict is resolved in Files/Obsidian + +The foreground app retries transient iCloud/provider availability without blocking the UI. A temporary cloud outage does **not** discard the saved bookmark or replace the UI with an empty task list. When a newly chosen cloud source is not current, Drawer keeps it as a staged selection; if another Drawer.md was already connected, that old source remains canonical and usable until the staged file is proven safe and atomically promoted. + +If the app is killed while a new source is still downloading, the staged bookmark is retained in the App Group. On relaunch Drawer reopens the previous canonical source when one exists, resumes validation of the staged selection, and promotes it only after a current UTF-8 read. Authentication and iCloud-conflict states preserve the staged grant too: fix the account/conflict in Files, Obsidian, or the provider app and return to Drawer instead of selecting the same file again. + +### Other Files providers + +Third-party providers exposed through Files use the same persisted bookmark + `NSFileCoordinator` boundary. Unlike iCloud, client apps do not have a universal public API for forcing every third-party provider to download a placeholder. If that provider is offline, signed out, or temporarily refuses the extension process, Drawer keeps the connection and last-known-good widget snapshot and reports the provider-specific recovery state instead of pretending a mutation succeeded. + +Only genuinely transient provider failures are automatically polled. Authentication and other user-action states preserve the selected grant but wait for the app to become active again; quota, filename-collision, and unrelated provider errors are surfaced as terminal read/write failures rather than mislabeled as an offline condition. + +The rule across every storage type is the same: **the selected `Drawer.md` is canonical; cached widget data is never promoted to source of truth.** + ## Widget writeback Interactive widget completion attempts the exact same canonical Markdown mutation as the app and refreshes its snapshot only after that write succeeds. This intentionally avoids optimistic widget state that could disagree with Obsidian. -If a widget action cannot regain File Provider access, the last known-good task snapshot stays intact and the widget shows a short-lived recovery indicator instead of pretending the task changed. Disconnect removes the shared snapshot and requests an immediate WidgetKit reload. +If a widget action cannot regain File Provider access, if iCloud is still materializing the file, or if an iCloud conflict exists, the last known-good task snapshot stays intact and the widget shows a short-lived recovery indicator instead of pretending the task changed. A staged replacement never changes widget truth: widgets continue targeting the previous canonical bookmark until the new source is promoted. Disconnect removes the shared snapshot and requests an immediate WidgetKit reload. External security-scoped bookmark behavior from a WidgetKit extension is sensitive to the selected File Provider and OS version. Validate interactive completion on a physical device with every storage provider you intend to support. @@ -65,27 +99,49 @@ Store signing and App Group provisioning still require a developer account / Xco ## Physical-device release acceptance -Simulator CI cannot prove File Provider grants, real Taptic Engine feel, lock-state extension behavior, or Apple signing. Before App Store submission, complete this matrix on the intended shipping iOS version and the exact storage provider used in production: +Simulator CI cannot prove File Provider grants, real Taptic Engine feel, lock-state extension behavior, or Apple signing. Before App Store submission, complete this matrix on the intended shipping iOS version. + +### Local / On My iPhone + +- [ ] Pick a real `Drawer.md` from **On My iPhone** (including an Obsidian local vault if exposed in Files); force-quit and relaunch; verify the bookmark reconnects without another picker prompt. +- [ ] Reboot the iPhone and verify the same local bookmark still reconnects. +- [ ] Edit the local file externally while Drawer is foregrounded; verify the UI refreshes and preserves the edit. +- [ ] Complete/add/move/delete from Drawer and verify the exact same file changes in Files/Obsidian. +- [ ] Complete a task from medium and large widgets against the local file; verify canonical Markdown changes before the widget changes. + +### iCloud Drive / Obsidian + +- [ ] Pick a real `Drawer.md` under `iCloud Drive/Obsidian/`; force-quit and relaunch; verify the bookmark reconnects without another picker prompt. +- [ ] Reboot the iPhone and verify the same iCloud bookmark still reconnects. +- [ ] Make the file available offline, edit it from another Apple device, and verify Drawer reads the newest cloud version rather than an older local copy. +- [ ] If the Files UI permits it, remove the local download / allow the item to be evicted; choose that file in Drawer and verify the selection is staged, materialization begins, and the file opens automatically only after it becomes current. +- [ ] While an evicted replacement is staged over an existing working Drawer.md, verify the old source remains visible, editable, and targeted by widgets until promotion. +- [ ] Force-quit Drawer while the replacement is still staged; relaunch and verify the old source returns immediately, the pending download/validation resumes, and the replacement is promoted without another picker prompt once current. +- [ ] With no previous source connected, force-quit during first-time iCloud materialization; relaunch and verify Drawer resumes the staged selection rather than asking for the same file again. +- [ ] Create or simulate an unresolved iCloud document conflict if practical; verify Drawer refuses canonical writes, retains the selected grant, and opens successfully after the conflict is resolved and Drawer becomes active again. +- [ ] Put the device offline while the item is not current; verify Drawer keeps the bookmark and last-known-good UI rather than emptying or overwriting the source. +- [ ] Restore connectivity and verify the app recovers without asking the user to choose the same file again. + +### Shared integrity / provider behavior - [ ] Select the correct Apple Developer team; app + widget identifiers provision successfully. - [ ] Confirm `group.com.bbrizly.drawer` (or the chosen replacement) is enabled for both app and widget and matches code + entitlements exactly. - [ ] Install a signed Release/TestFlight build on a physical iPhone. -- [ ] Pick a real `Drawer.md` in iCloud Drive / the Obsidian vault; force-quit and relaunch; verify the bookmark reconnects without another picker prompt. -- [ ] Reboot the iPhone and verify the same bookmark still reconnects. - [ ] Edit `Drawer.md` externally while Drawer is foregrounded; verify the UI refreshes and the external edit is preserved. - [ ] Race an external edit with a Drawer complete/move/add operation; verify neither side is silently clobbered. - [ ] Change Drawer.md to an unreadable/non-UTF-8 file and verify the previous good connection is retained and a useful error is shown. -- [ ] Complete a task from medium and large widgets; verify canonical Markdown changes before the widget changes. - [ ] Deny/revoke the widget's external-file access if the provider permits it; verify the widget keeps old truth, shows recovery UI, and never marks the task complete. - [ ] Test widget completion while the device is locked, immediately after unlock, and after Drawer has been force-quit. +- [ ] Test any third-party Files provider you intend to advertise; sign out/go offline and verify Drawer preserves the selected grant/cache and reports provider recovery instead of false success. +- [ ] For a provider that requires sign-in, choose its file, trigger authentication loss if practical, fix the account outside Drawer, and verify the same staged/active bookmark recovers without another file selection. - [ ] Disconnect Drawer and verify Home/Lock Screen widgets stop intentionally showing the old task snapshot. - [ ] Start, pause, resume, background, force-quit, and relaunch a Focus session; verify absolute remaining time is correct. - [ ] With notification permission allowed, verify the background Focus completion alert fires once; with permission denied, verify Drawer explicitly says notifications are off. - [ ] Check completion/add/delete/move/focus haptics on real hardware; verify no haptic fires for ordinary scrolling/navigation. - [ ] Enable Reduce Motion and repeat the primary flows; state changes remain obvious without relying on animation. -- [ ] Run VoiceOver through connect, add, complete/reopen, progress, delete/undo, note save failure/retry, Focus, and widget recovery messaging. +- [ ] Run VoiceOver through connect, add, complete/reopen, progress, delete/undo, note save failure/retry, Focus, provider-sync recovery, and widget recovery messaging. - [ ] Exercise Dynamic Type through accessibility sizes; task titles remain readable and primary actions remain reachable. - [ ] Archive the signed Release build in Xcode and inspect Organizer validation for signing, privacy manifest, icon, extension, and App Group warnings. -- [ ] Upload to TestFlight, install the processed build, and repeat the bookmark + widget + Focus smoke path before App Store submission. +- [ ] Upload to TestFlight, install the processed build, and repeat both the local-file and iCloud bookmark/widget/Focus smoke paths before App Store submission. A release is not considered device-validated until the applicable checks above have been performed on hardware. Repository CI is the automated gate; this matrix is the platform-integration gate. diff --git a/iOS/RELEASE.md b/iOS/RELEASE.md new file mode 100644 index 0000000..7df52ad --- /dev/null +++ b/iOS/RELEASE.md @@ -0,0 +1,114 @@ +# Drawer iOS Release Acceptance + +This is the final release checklist for the native iPhone companion. `Drawer.md` remains the only task source of truth. CI proves repository behavior; signed hardware validation proves Apple platform integration. + +## App Review setup + +Drawer does **not** require an account, backend, Obsidian installation, or network service to be reviewed. + +A reviewer can exercise the complete core app with a plain Markdown file in Files: + +1. In Files, create a text file named `Drawer.md` under **On My iPhone** or **iCloud Drive**. +2. Put this sample content in it: + +```markdown +## 2026-08-31 +- [ ] Review Drawer (25m) +- [/] Test the Focus timer (5m) + This note should stay attached to the task. +- [ ] Recurring example + + +## 2026-09-01 +- [ ] Tomorrow task + +## Backlog +- [ ] Backlog example +``` + +3. Open Drawer and choose that `Drawer.md` with the system document picker. +4. Add, edit, complete/reopen, mark in progress, move and delete/undo tasks. +5. Start Focus from a task. With notifications permitted, background the app and verify the completion alert. On devices that support Live Activities, verify the Lock Screen / Dynamic Island surface. +6. Add a Drawer widget and complete a task from the widget. The Markdown file is canonical; the widget only changes after the canonical write succeeds. + +Obsidian is optional. If installed, Drawer can point at the same `Drawer.md` in an Obsidian vault, but App Review does not need it. + +## Exact automated gate + +The merge candidate must pass on its exact head SHA: + +- shared `DrawerCore` tests +- existing macOS Drawer Release package and signature regression +- iOS plist / entitlement / privacy-manifest invariants +- matching app + widget App Group +- App Group UserDefaults required-reason declaration +- 1024×1024 opaque App Store icon validation +- Debug iPhone Simulator app + widget tests +- optimized unsigned iOS Release app + widget build + +No earlier SHA counts as release evidence after the branch moves. + +## Physical iPhone acceptance + +### Canonical file and editing + +- [ ] Pick a local **On My iPhone** `Drawer.md`, force-quit, relaunch and reboot; the bookmark reconnects without another picker prompt. +- [ ] Pick an iCloud Drive `Drawer.md`, force-quit, relaunch and reboot; the bookmark reconnects without another picker prompt. +- [ ] Edit `Drawer.md` externally while Drawer is foregrounded; Drawer refreshes without erasing the external edit. +- [ ] Race an external edit with complete/add/edit/move/delete; neither side is silently clobbered. +- [ ] Edit title + Focus duration + note in one Save. Verify all three persist together in Markdown. +- [ ] Edit only the title of a task with a custom duration such as `(45m)`; the 45-minute duration remains intact. +- [ ] Edit a recurring task; Drawer-owned recurrence metadata remains attached and recurrence still advances exactly once on completion. +- [ ] Delete or move, then externally modify the file before Undo; Drawer must refuse any Undo that could overwrite newer external bytes. +- [ ] Change to a bad/unreadable/non-UTF-8 replacement; the existing good file remains canonical. + +### iCloud / Files provider recovery + +- [ ] Test an evicted/not-downloaded iCloud item. Drawer requests materialization and never writes the placeholder/stale copy. +- [ ] Stage an unavailable replacement while a good file is connected. The old file remains visible, editable and widget-targeted until the replacement produces a current UTF-8 read. +- [ ] Force-quit during staged replacement and relaunch; the old canonical source returns and pending validation resumes. +- [ ] Exercise an authentication-required provider state; fix access in Files/provider UI and return to Drawer without reselecting the file. +- [ ] Exercise an unresolved iCloud conflict if practical; Drawer refuses canonical writes until it is resolved. +- [ ] Test any third-party Files provider you intend to advertise while offline/signed out; no optimistic mutation is shown. + +### Widgets and ambient surfaces + +- [ ] Add small, medium and large Home Screen widgets plus supported Lock Screen widgets. +- [ ] Verify carried tasks are ordered before today's unfinished tasks in the Next surface. +- [ ] Complete from medium/large widget against a local file; Markdown changes before widget truth changes. +- [ ] Repeat widget completion with Drawer force-quit, after unlock, and while the device is locked when the OS permits interaction. +- [ ] Make provider access fail during a widget mutation; the last-known-good snapshot stays visible with recovery messaging. +- [ ] Disconnect Drawer; widgets stop intentionally showing the old task snapshot. + +### Focus / Live Activity + +- [ ] Start Focus and verify the in-app timer, persisted session and Live Activity show the same task and remaining time. +- [ ] Pause Focus; the Live Activity stops counting and shows the paused value. +- [ ] Resume Focus; the Live Activity resumes from the persisted remaining time. +- [ ] End/reset Focus; the Live Activity ends instead of lingering as a stale countdown. +- [ ] Let Focus finish; the Live Activity reaches the completed state and the completion notification fires once when authorized. +- [ ] Background, lock, force-quit and relaunch during a running Focus; remaining time is derived from the absolute end date and does not reset. +- [ ] Deny notification permission; Focus still works and Drawer clearly reports that completion notifications are unavailable. +- [ ] Confirm task titles are appropriately protected on privacy-sensitive widget/Live Activity surfaces when the device is locked. + +### Tactile, accessibility and layout + +- [ ] Verify completion, reopen, add, progress, recurrence, move, delete, undo and Focus haptics on a physical Taptic Engine. +- [ ] Ordinary navigation and scrolling do not vibrate. +- [ ] Swipe thresholds feel deliberate; destructive delete feedback occurs only at the destructive threshold. +- [ ] Enable Reduce Motion and repeat primary flows; all state changes remain understandable without motion. +- [ ] Run VoiceOver through connection, capture, task row actions, edit/save, delete/undo, Focus, provider recovery and widgets. +- [ ] Run Dynamic Type through accessibility sizes in portrait and landscape; task titles remain readable and primary controls remain reachable. +- [ ] Verify frequent controls meet comfortable iPhone touch-target sizing and are not clipped by larger text. + +### Signing / distribution + +- [ ] Select the shipping Apple Developer team. +- [ ] Confirm `com.bbrizly.drawer`, `com.bbrizly.drawer.widgets`, and `group.com.bbrizly.drawer` (or the deliberate shipping replacements) are provisioned consistently. +- [ ] Install a signed Release build on a physical iPhone. +- [ ] Archive in Xcode and pass Organizer validation for signing, icon, privacy manifest, App Group, widgets and Live Activities. +- [ ] Upload to TestFlight, install the processed build, and repeat the local-file, iCloud, widget and Focus smoke paths. + +## Release rule + +Do not claim hardware behaviors as proven by Simulator CI. The repository can be merged when the exact-head automated gate is green. App Store submission remains blocked only by the signed-device checks above that require Apple credentials, real providers, lock state, Taptic Engine and TestFlight processing. diff --git a/iOS/Shared/DrawerBookmarkStore.swift b/iOS/Shared/DrawerBookmarkStore.swift index 479cf0c..1ff012b 100644 --- a/iOS/Shared/DrawerBookmarkStore.swift +++ b/iOS/Shared/DrawerBookmarkStore.swift @@ -2,6 +2,7 @@ import Foundation enum DrawerBookmarkError: LocalizedError { case missingBookmark + case missingPendingBookmark case accessDenied case invalidEncoding case appGroupUnavailable @@ -10,6 +11,8 @@ enum DrawerBookmarkError: LocalizedError { switch self { case .missingBookmark: "No Drawer.md is connected." + case .missingPendingBookmark: + "The pending Drawer.md selection is no longer available. Choose the file again." case .accessDenied: "Drawer no longer has permission to access that file. Choose Drawer.md again." case .invalidEncoding: @@ -20,48 +23,79 @@ enum DrawerBookmarkError: LocalizedError { } } +enum DrawerBookmarkSaveOutcome: Equatable { + case ready + case staged +} + enum DrawerBookmarkStore { static var hasBookmark: Bool { guard DrawerShared.containerURL != nil else { return false } return DrawerShared.defaults.data(forKey: DrawerShared.bookmarkKey) != nil } - /// Persist the document picker grant only after proving the selected URL is - /// readable UTF-8 text. A bad replacement therefore cannot overwrite the - /// last known-good bookmark before Drawer knows it can actually use it. - static func save(_ pickedURL: URL) throws { + static var hasPendingBookmark: Bool { + guard DrawerShared.containerURL != nil else { return false } + return DrawerShared.defaults.data(forKey: DrawerShared.pendingBookmarkKey) != nil + } + + /// Persist a document-picker grant transactionally. + /// + /// A normal/local selection is promoted only after proving it is readable + /// UTF-8 Markdown. If iCloud or another Files provider has granted the URL + /// but the source needs download, provider recovery, authentication, or + /// conflict resolution, keep the new bookmark in a separate pending slot. + /// The previous canonical bookmark remains intact until the pending source + /// produces a real successful read. + static func save(_ pickedURL: URL) throws -> DrawerBookmarkSaveOutcome { guard DrawerShared.containerURL != nil else { throw DrawerBookmarkError.appGroupUnavailable } - let started = pickedURL.startAccessingSecurityScopedResource() - defer { - if started { pickedURL.stopAccessingSecurityScopedResource() } - } + let data = try makeBookmarkData(for: pickedURL) + let probe = DrawerFileSession(url: pickedURL) - // A file picked from Files normally returns true above. Still attempt - // bookmark creation when it doesn't: iOS' file security model can keep - // a valid scoped URL even when the explicit start call reports false. - let data = try pickedURL.bookmarkData( - options: [], - includingResourceValuesForKeys: [.nameKey, .contentModificationDateKey], - relativeTo: nil - ) + do { + let contents = try probe.read() + guard String(data: contents, encoding: .utf8) != nil else { + throw DrawerBookmarkError.invalidEncoding + } - let probe = DrawerFileSession(url: pickedURL) - let contents = try probe.read() - guard String(data: contents, encoding: .utf8) != nil else { - throw DrawerBookmarkError.invalidEncoding + DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) + return .ready + } catch let accessError as DrawerFileAccessError where accessError.preservesSelectedGrant { + // A newer viable selection supersedes an older pending attempt. + // Terminal validation failures never disturb the source (primary or + // pending) that was already in use. + DrawerShared.defaults.set(data, forKey: DrawerShared.pendingBookmarkKey) + return .staged } - - // Commit the bookmark last. Until this point an existing connection is - // untouched, which makes Change Drawer.md transactional from the user's - // perspective. - DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) } static func clear() { DrawerShared.defaults.removeObject(forKey: DrawerShared.bookmarkKey) + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) + } + + static func discardPending() { + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) + } + + /// Commit a staged source only after its session has completed a real UTF-8 + /// canonical read. This preserves Change Drawer.md's rollback guarantee even + /// when a File Provider needed time or user action before making the source + /// safe to read. + static func promotePending() throws { + guard DrawerShared.containerURL != nil else { + throw DrawerBookmarkError.appGroupUnavailable + } + guard let data = DrawerShared.defaults.data(forKey: DrawerShared.pendingBookmarkKey) else { + throw DrawerBookmarkError.missingPendingBookmark + } + + DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) } static func openSession() throws -> DrawerFileSession { @@ -72,6 +106,21 @@ enum DrawerBookmarkStore { throw DrawerBookmarkError.missingBookmark } + return try openSession(from: data, refreshKey: DrawerShared.bookmarkKey) + } + + static func openPendingSession() throws -> DrawerFileSession { + guard DrawerShared.containerURL != nil else { + throw DrawerBookmarkError.appGroupUnavailable + } + guard let data = DrawerShared.defaults.data(forKey: DrawerShared.pendingBookmarkKey) else { + throw DrawerBookmarkError.missingPendingBookmark + } + + return try openSession(from: data, refreshKey: DrawerShared.pendingBookmarkKey) + } + + private static func openSession(from data: Data, refreshKey: String) throws -> DrawerFileSession { var stale = false let url = try URL( resolvingBookmarkData: data, @@ -82,11 +131,33 @@ enum DrawerBookmarkStore { let session = DrawerFileSession(url: url) if stale { - // Refresh only after access is established. Failure to refresh the - // bookmark should not throw away a session that can still read the - // user's file right now. - try? save(url) + // This is the same logical selection, not a source replacement, so + // refreshing bookmark bytes is safe even if the provider is still + // making file contents available. + if let refreshed = try? makeBookmarkData(for: url) { + DrawerShared.defaults.set(refreshed, forKey: refreshKey) + } } return session } + + private static func makeBookmarkData(for url: URL) throws -> Data { + let started = url.startAccessingSecurityScopedResource() + defer { + if started { url.stopAccessingSecurityScopedResource() } + } + + // A file picked from Files normally returns true above. Still attempt + // bookmark creation when it doesn't: iOS' file security model can keep + // a valid scoped URL even when the explicit start call reports false. + return try url.bookmarkData( + options: [], + includingResourceValuesForKeys: [ + .nameKey, + .contentModificationDateKey, + .isUbiquitousItemKey, + ], + relativeTo: nil + ) + } } diff --git a/iOS/Shared/DrawerFileSession.swift b/iOS/Shared/DrawerFileSession.swift index acd95dc..eb891fe 100644 --- a/iOS/Shared/DrawerFileSession.swift +++ b/iOS/Shared/DrawerFileSession.swift @@ -1,15 +1,111 @@ +import FileProvider import Foundation +enum DrawerStorageKind: Equatable, Sendable { + case iCloudDrive + case files + + var displayName: String { + switch self { + case .iCloudDrive: "iCloud Drive" + case .files: "On My iPhone / Files" + } + } +} + +enum DrawerFileAccessError: LocalizedError { + case waitingForICloud + case iCloudConflict + case providerUnavailable(DrawerStorageKind) + case authenticationRequired(DrawerStorageKind) + case itemMissing + case permissionDenied + case notRegularFile + case readFailed(String) + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .waitingForICloud: + "Drawer.md is syncing from iCloud. Drawer will retry automatically." + case .iCloudConflict: + "iCloud has an unresolved conflict for Drawer.md. Resolve it in Files or Obsidian before Drawer writes anything." + case .providerUnavailable(let storage): + "\(storage.displayName) isn't available right now. Drawer kept your connection and will retry." + case .authenticationRequired(let storage): + "\(storage.displayName) needs account access again. Open Files or the provider app, sign in if needed, then return to Drawer." + case .itemMissing: + "Drawer.md was moved or deleted. If it still exists, choose it again." + case .permissionDenied: + "Drawer no longer has permission to access Drawer.md. Choose the file again." + case .notRegularFile: + "Choose a Markdown file, not a folder or package." + case .readFailed(let message): + "Drawer couldn't read Drawer.md: \(message)" + case .writeFailed(let message): + "Drawer couldn't save Drawer.md: \(message)" + } + } + + /// Conditions that can heal by waiting while Drawer remains foregrounded. + var isTransient: Bool { + switch self { + case .waitingForICloud, .providerUnavailable: + true + default: + false + } + } + + /// Conditions where the document-picker grant is still valuable even though + /// the bytes cannot be used yet. Authentication and iCloud conflicts need + /// user action, not another picker round-trip; Drawer retries them when the + /// app becomes active again after the user fixes the provider state. + var preservesSelectedGrant: Bool { + switch self { + case .waitingForICloud, .providerUnavailable, .authenticationRequired, .iCloudConflict: + true + default: + false + } + } + + var widgetMessage: String { + switch self { + case .waitingForICloud: + "Drawer.md is syncing from iCloud. Open Drawer to finish syncing, then retry." + case .providerUnavailable: + "Drawer.md's Files provider is unavailable. Open Drawer to retry." + case .authenticationRequired: + "Drawer.md's Files provider needs account access. Open Drawer to reconnect." + case .iCloudConflict: + "Drawer.md has an iCloud conflict. Resolve it in Files or Obsidian first." + case .itemMissing, .permissionDenied: + "Open Drawer to reconnect Drawer.md." + case .notRegularFile, .readFailed, .writeFailed: + "Update failed. Open Drawer and try again." + } + } +} + /// A short-lived, explicitly scoped handle to the user's canonical Drawer.md. /// Every read/write is coordinated so Obsidian, iCloud and File Provider apps /// get a chance to reconcile their own state around the operation. +/// +/// The source may be an On My iPhone file, an iCloud Drive item, or a document +/// exposed by another Files provider. iCloud is special-cased because Apple can +/// leave a stale/evicted local placeholder. Drawer never reads that placeholder +/// as canonical truth and never writes until iCloud reports the local copy is +/// current. final class DrawerFileSession { let url: URL + let storageKind: DrawerStorageKind private let didStartAccess: Bool init(url: URL) { self.url = url self.didStartAccess = url.startAccessingSecurityScopedResource() + self.storageKind = Self.storageKind(for: url) } deinit { @@ -19,6 +115,8 @@ final class DrawerFileSession { } func read() throws -> Data { + try preflight(writing: false) + var coordinationError: NSError? var readError: Error? var result: Data? @@ -35,13 +133,19 @@ final class DrawerFileSession { } } - if let coordinationError { throw coordinationError } - if let readError { throw readError } - guard let result else { throw DrawerBookmarkError.accessDenied } + if let coordinationError { + throw Self.mapAccessError(coordinationError, storage: storageKind, writing: false) + } + if let readError { + throw Self.mapAccessError(readError, storage: storageKind, writing: false) + } + guard let result else { throw DrawerFileAccessError.permissionDenied } return result } func write(_ data: Data) throws { + try preflight(writing: true) + var coordinationError: NSError? var writeError: Error? @@ -57,7 +161,131 @@ final class DrawerFileSession { } } - if let coordinationError { throw coordinationError } - if let writeError { throw writeError } + if let coordinationError { + throw Self.mapAccessError(coordinationError, storage: storageKind, writing: true) + } + if let writeError { + throw Self.mapAccessError(writeError, storage: storageKind, writing: true) + } + } + + /// Public to the test target through @testable so the storage invariant is + /// regression-tested without requiring an actual iCloud account in CI. + static func iCloudNeedsMaterialization( + _ status: URLUbiquitousItemDownloadingStatus? + ) -> Bool { + status == .notDownloaded || status == .downloaded + } + + private func preflight(writing: Bool) throws { + let keys: Set = [ + .isRegularFileKey, + .isReadableKey, + .isWritableKey, + .isUbiquitousItemKey, + .ubiquitousItemDownloadingStatusKey, + .ubiquitousItemDownloadingErrorKey, + .ubiquitousItemHasUnresolvedConflictsKey, + ] + + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: keys) + } catch { + // Some providers are lazy about metadata even though coordinated + // file access succeeds. Only stop here for errors we can identify + // as meaningful access failures; otherwise let coordination decide. + if let known = Self.knownAccessError(error, storage: storageKind) { + throw known + } + return + } + + if values.isRegularFile == false { + throw DrawerFileAccessError.notRegularFile + } + + if values.isUbiquitousItem == true { + if values.ubiquitousItemHasUnresolvedConflicts == true { + throw DrawerFileAccessError.iCloudConflict + } + + if let downloadError = values.ubiquitousItemDownloadingError { + throw Self.mapAccessError(downloadError, storage: .iCloudDrive, writing: writing) + } + + if Self.iCloudNeedsMaterialization(values.ubiquitousItemDownloadingStatus) { + do { + try FileManager.default.startDownloadingUbiquitousItem(at: url) + } catch { + throw Self.mapAccessError(error, storage: .iCloudDrive, writing: writing) + } + throw DrawerFileAccessError.waitingForICloud + } + } + + if values.isReadable == false { + throw DrawerFileAccessError.permissionDenied + } + if writing, values.isWritable == false { + throw DrawerFileAccessError.permissionDenied + } + } + + private static func storageKind(for url: URL) -> DrawerStorageKind { + let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey]) + return values?.isUbiquitousItem == true ? .iCloudDrive : .files + } + + private static func knownAccessError( + _ error: Error, + storage: DrawerStorageKind + ) -> DrawerFileAccessError? { + if let access = error as? DrawerFileAccessError { return access } + let nsError = error as NSError + + if nsError.domain == NSFileProviderErrorDomain, + let code = NSFileProviderError.Code(rawValue: nsError.code) { + switch code { + case .notAuthenticated: + return .authenticationRequired(storage) + case .serverUnreachable: + return .providerUnavailable(storage) + case .noSuchItem: + return .itemMissing + default: + // Quota, collision, sync-anchor and other provider errors are + // not necessarily transient connectivity failures. Let the + // caller surface their real localized read/write failure rather + // than entering an automatic retry loop that cannot fix them. + return nil + } + } + + if nsError.domain == NSCocoaErrorDomain { + switch nsError.code { + case NSFileReadNoPermissionError, NSFileWriteNoPermissionError: + return .permissionDenied + case NSFileNoSuchFileError: + return .itemMissing + default: + break + } + } + + return nil + } + + private static func mapAccessError( + _ error: Error, + storage: DrawerStorageKind, + writing: Bool + ) -> DrawerFileAccessError { + if let known = knownAccessError(error, storage: storage) { + return known + } + + let message = (error as NSError).localizedDescription + return writing ? .writeFailed(message) : .readFailed(message) } } diff --git a/iOS/Shared/DrawerShared.swift b/iOS/Shared/DrawerShared.swift index 820b8bf..655997e 100644 --- a/iOS/Shared/DrawerShared.swift +++ b/iOS/Shared/DrawerShared.swift @@ -1,8 +1,10 @@ +import ActivityKit import Foundation enum DrawerShared { static let appGroupIdentifier = "group.com.bbrizly.drawer" static let bookmarkKey = "drawer.mobile.bookmark.v1" + static let pendingBookmarkKey = "drawer.mobile.bookmark.pending.v1" static let snapshotFilename = "drawer-widget-snapshot-v1.json" static let focusSessionKey = "drawer.focus.session.v1" @@ -32,6 +34,27 @@ struct DrawerPersistedFocus: Codable, Equatable, Sendable { let createdAt: Date } +/// Shared by the app and widget extension so Focus can leave the app without +/// creating a second timer. Running state carries an absolute end date; the +/// system renders the countdown itself even while Drawer is suspended. +struct DrawerFocusActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable, Sendable { + enum Phase: String, Codable, Hashable, Sendable { + case running + case paused + case finished + case ended + } + + let phase: Phase + let endDate: Date? + let remaining: TimeInterval + } + + let sessionID: UUID + let taskTitle: String +} + enum DrawerFocusStore { static func load() -> DrawerPersistedFocus? { guard let data = DrawerShared.defaults.data(forKey: DrawerShared.focusSessionKey) else { return nil } diff --git a/iOS/Shared/WidgetSnapshot.swift b/iOS/Shared/WidgetSnapshot.swift index 8b6ac33..2774721 100644 --- a/iOS/Shared/WidgetSnapshot.swift +++ b/iOS/Shared/WidgetSnapshot.swift @@ -152,8 +152,9 @@ enum WidgetSnapshotStore { /// Best-effort canonical refresh for WidgetKit and App Intent queries. /// External Obsidian/iCloud edits and a new local day should appear even if /// the Drawer app itself has not launched. If the provider refuses access, - /// or if the external file is temporarily invalid UTF-8, preserve the last - /// known-good cache rather than manufacturing an empty task state. + /// if iCloud is still materializing the canonical file, or if the external + /// file is temporarily invalid UTF-8, preserve the last known-good cache + /// rather than manufacturing an empty or stale task state. static func current(todayKey: String = DrawerDate.todayKey()) -> WidgetSnapshot { let cached = read() do { @@ -190,9 +191,10 @@ struct WidgetInteractionFeedback: Equatable, Sendable { } /// A widget mutation may fail even while its last snapshot remains valid—for -/// example when a File Provider temporarily refuses the extension's bookmark. -/// Keep failure UI separate from task truth so the widget can say "still old" -/// without ever pretending the Markdown mutation succeeded. +/// example when iCloud has evicted Drawer.md or a File Provider temporarily +/// refuses the extension's bookmark. Keep failure UI separate from task truth +/// so the widget can say "still old" without ever pretending the Markdown +/// mutation succeeded. enum WidgetInteractionFeedbackStore { private static let messageKey = "drawer.widget.interaction-error.message.v1" private static let dateKey = "drawer.widget.interaction-error.date.v1" @@ -200,7 +202,9 @@ enum WidgetInteractionFeedbackStore { static func recordFailure(_ error: Error) { let message: String - if error is DrawerBookmarkError { + if let accessError = error as? DrawerFileAccessError { + message = accessError.widgetMessage + } else if error is DrawerBookmarkError { message = "Open Drawer to reconnect Drawer.md." } else { message = "Update failed. Open Drawer and try again." diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 12ca9d8..1edada9 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -1,4 +1,5 @@ import DrawerCore +import Foundation import XCTest @testable import DrawerMobile @@ -39,6 +40,24 @@ final class DrawerMobileSharedTests: XCTestCase { XCTAssertEqual(snapshot.upcomingLabel, "Tomorrow") } + func testNextWidgetPrefersCarriedThenTodayAndSkipsCompleted() { + let data = """ + ## 2026-08-30 + - [x] Completed yesterday + - [ ] Oldest unfinished + + ## 2026-08-31 + - [x] Finished today + - [ ] Current task + """.data(using: .utf8)! + + let snapshot = WidgetSnapshot.make(from: data, todayKey: "2026-08-31") + + XCTAssertEqual(snapshot.actionableTasks.map(\.title), ["Oldest unfinished", "Current task"]) + XCTAssertEqual(snapshot.actionableTasks.first?.bucket, .carried) + XCTAssertEqual(snapshot.remaining, 2) + } + func testWidgetSnapshotRoundTrips() throws { let data = "## 2026-08-28\n- [ ] One\n".data(using: .utf8)! let snapshot = WidgetSnapshot.make(from: data, todayKey: "2026-08-28") @@ -59,6 +78,121 @@ final class DrawerMobileSharedTests: XCTestCase { ) } + func testWidgetExplainsICloudMaterializationWithoutChangingTruth() { + let before = Date() + WidgetInteractionFeedbackStore.recordFailure(DrawerFileAccessError.waitingForICloud) + + let visible = WidgetInteractionFeedbackStore.current(now: before.addingTimeInterval(1)) + XCTAssertEqual( + visible?.message, + "Drawer.md is syncing from iCloud. Open Drawer to finish syncing, then retry." + ) + } + + func testICloudStaleAndEvictedStatesRequireMaterialization() { + XCTAssertTrue(DrawerFileSession.iCloudNeedsMaterialization(.notDownloaded)) + XCTAssertTrue(DrawerFileSession.iCloudNeedsMaterialization(.downloaded)) + XCTAssertFalse(DrawerFileSession.iCloudNeedsMaterialization(.current)) + XCTAssertFalse(DrawerFileSession.iCloudNeedsMaterialization(nil)) + } + + func testPlainLocalFileSessionReadsAndWritesWithoutSecurityScope() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("Drawer.md") + let original = "## 2026-08-31\n- [ ] Local\n".data(using: .utf8)! + let updated = "## 2026-08-31\n- [x] Local\n".data(using: .utf8)! + try original.write(to: url) + + let session = DrawerFileSession(url: url) + XCTAssertEqual(session.storageKind, .files) + XCTAssertEqual(try session.read(), original) + + try session.write(updated) + XCTAssertEqual(try Data(contentsOf: url), updated) + XCTAssertEqual(try session.read(), updated) + } + + func testProviderErrorsDistinguishAutomaticRetryFromGrantPreservation() { + let unavailable = DrawerFileAccessError.providerUnavailable(.files) + XCTAssertTrue(unavailable.isTransient) + XCTAssertTrue(unavailable.preservesSelectedGrant) + XCTAssertEqual( + unavailable.widgetMessage, + "Drawer.md's Files provider is unavailable. Open Drawer to retry." + ) + + let authentication = DrawerFileAccessError.authenticationRequired(.files) + XCTAssertFalse(authentication.isTransient) + XCTAssertTrue(authentication.preservesSelectedGrant) + + let conflict = DrawerFileAccessError.iCloudConflict + XCTAssertFalse(conflict.isTransient) + XCTAssertTrue(conflict.preservesSelectedGrant) + + let permission = DrawerFileAccessError.permissionDenied + XCTAssertFalse(permission.isTransient) + XCTAssertFalse(permission.preservesSelectedGrant) + XCTAssertEqual(permission.widgetMessage, "Open Drawer to reconnect Drawer.md.") + + let missing = DrawerFileAccessError.itemMissing + XCTAssertFalse(missing.isTransient) + XCTAssertFalse(missing.preservesSelectedGrant) + } + + func testAtomicTaskEditPreservesCheckboxDurationAndReplacesNote() throws { + let original = """ + ## 2026-08-31 + - [/] Original (45m) + old note + + """.data(using: .utf8)! + let item = try XCTUnwrap( + TodoParser.parse(String(decoding: original, as: UTF8.self)).first?.items.first + ) + + var edited = try TodoMetadataWriteback.setNote( + line: item.rawLine, + sectionDate: item.sectionDate, + occurrence: item.occurrence, + note: "new note", + in: original + ) + edited = try TodoWriteback.rename( + line: item.rawLine, + sectionDate: item.sectionDate, + occurrence: item.occurrence, + to: "Renamed (45m)", + in: edited + ) + + let parsed = try XCTUnwrap( + TodoParser.parse(String(decoding: edited, as: UTF8.self)).first?.items.first + ) + XCTAssertEqual(parsed.title, "Renamed") + XCTAssertEqual(parsed.minutes, 45) + XCTAssertTrue(parsed.isInProgress) + XCTAssertEqual(parsed.note, "new note") + XCTAssertTrue(String(decoding: edited, as: UTF8.self).contains("")) + } + + func testFocusLiveActivityStateRoundTrips() throws { + let state = DrawerFocusActivityAttributes.ContentState( + phase: .running, + endDate: Date(timeIntervalSince1970: 1_800_001_500), + remaining: 900 + ) + let encoded = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode( + DrawerFocusActivityAttributes.ContentState.self, + from: encoded + ) + XCTAssertEqual(decoded, state) + } + func testObsidianLinkStripsAlias() { let link = ObsidianLink.first(in: "Finish [[QCM Mobile|the mobile plan]] today") XCTAssertEqual(link?.note, "QCM Mobile")