From 0efc97e41b51af3642953380319687b66141dd6a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:19:35 -0300 Subject: [PATCH 01/58] Harden local and iCloud file access --- iOS/Shared/DrawerFileSession.swift | 220 ++++++++++++++++++++++++++++- 1 file changed, 215 insertions(+), 5 deletions(-) diff --git a/iOS/Shared/DrawerFileSession.swift b/iOS/Shared/DrawerFileSession.swift index acd95dc..5ed6074 100644 --- a/iOS/Shared/DrawerFileSession.swift +++ b/iOS/Shared/DrawerFileSession.swift @@ -1,15 +1,97 @@ +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)" + } + } + + var isTransient: Bool { + switch self { + case .waitingForICloud, .providerUnavailable: + 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 +101,8 @@ final class DrawerFileSession { } func read() throws -> Data { + try preflight(writing: false) + var coordinationError: NSError? var readError: Error? var result: Data? @@ -35,13 +119,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 +147,127 @@ 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: + return .providerUnavailable(storage) + } + } + + 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) } } From 84026a1a4bf277d3c0a915b0155ba5b2e962ddef Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:20:54 -0300 Subject: [PATCH 02/58] Retry transient Files provider access without blocking UI --- .../Model/DrawerMobileModel.swift | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index 285d0f7..b5001c6 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -36,6 +36,8 @@ final class DrawerMobileModel: ObservableObject { private var lastAppliedDayKey: String? private var undoPayload: UndoPayload? private var undoExpiryTask: Task? + private var providerRetryTask: Task? + private var hasTransientAccessFailure = false private var isSceneActive = true private var focusSessionID: UUID? private var focusCreatedAt: Date? @@ -81,6 +83,8 @@ final class DrawerMobileModel: ObservableObject { document?.stopObserving() document = nil clearUndo() + cancelProviderRetry() + hasTransientAccessFailure = false lastAppliedData = nil lastAppliedDayKey = nil carriedItems = [] @@ -101,7 +105,10 @@ final class DrawerMobileModel: ObservableObject { func setSceneActive(_ active: Bool) { isSceneActive = active focusTimer.setDisplayActive(active) - if !active { persistFocusState() } + if !active { + persistFocusState() + cancelProviderRetry() + } guard let document else { return } if active { startObserving(document) @@ -126,7 +133,14 @@ 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 + statusMessage = nil + } + cancelProviderRetry() + return + } // Automatic recurrence/archive normalization is a canonical write, // so it follows the same one-retry content-CAS rule as a user @@ -424,7 +438,9 @@ final class DrawerMobileModel: ObservableObject { return CommitResult(before: base, after: canonical) } catch { fail(error) - reload() + if (error as? DrawerFileAccessError)?.isTransient != true { + reload() + } return nil } } @@ -463,6 +479,8 @@ final class DrawerMobileModel: ObservableObject { } else { upcomingLabel = "" } + hasTransientAccessFailure = false + cancelProviderRetry() statusMessage = nil lastAppliedData = data lastAppliedDayKey = today @@ -488,6 +506,8 @@ final class DrawerMobileModel: ObservableObject { let newDocument = CoordinatedDrawerDocument(session: try DrawerBookmarkStore.openSession()) document?.stopObserving() clearUndo() + cancelProviderRetry() + hasTransientAccessFailure = false document = newDocument sourceName = newDocument.url.lastPathComponent connectionState = .connected @@ -612,8 +632,69 @@ final class DrawerMobileModel: ObservableObject { undoLabel = nil } + private func scheduleProviderRetry() { + guard isSceneActive, document != nil, providerRetryTask == nil else { return } + + providerRetryTask = Task { [weak self] in + let delays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + .seconds(3), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + ] + + for delay in delays { + 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 } + } + + self?.providerRetryTask = nil + } + } + + private func cancelProviderRetry() { + providerRetryTask?.cancel() + providerRetryTask = nil + } + private func fail(_ error: Error) { - statusMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - DrawerHaptics.shared.error() + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + let changed = statusMessage != message + statusMessage = message + + if let accessError = error as? DrawerFileAccessError { + hasTransientAccessFailure = accessError.isTransient + if accessError.isTransient { + scheduleProviderRetry() + return + } + } else { + hasTransientAccessFailure = false + } + + cancelProviderRetry() + if changed { + DrawerHaptics.shared.error() + } } } From 75c087ee214d6168c90414fb4b567d77072c790d Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:21:24 -0300 Subject: [PATCH 03/58] Explain provider-specific widget failures --- iOS/Shared/WidgetSnapshot.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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." From 8c2126bdc466ab551e591afd42b1905ca9c8b995 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:21:49 -0300 Subject: [PATCH 04/58] Cover local files and iCloud materialization semantics --- iOS/Tests/DrawerMobileSharedTests.swift | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 12ca9d8..6a1fefc 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -1,4 +1,5 @@ import DrawerCore +import Foundation import XCTest @testable import DrawerMobile @@ -59,6 +60,57 @@ 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 testProviderErrorsKeepUsefulRecoverySemantics() { + let transient = DrawerFileAccessError.providerUnavailable(.files) + XCTAssertTrue(transient.isTransient) + XCTAssertEqual( + transient.widgetMessage, + "Drawer.md's Files provider is unavailable. Open Drawer to retry." + ) + + let permission = DrawerFileAccessError.permissionDenied + XCTAssertFalse(permission.isTransient) + XCTAssertEqual(permission.widgetMessage, "Open Drawer to reconnect Drawer.md.") + } + func testObsidianLinkStripsAlias() { let link = ObsidianLink.first(in: "Finish [[QCM Mobile|the mobile plan]] today") XCTAssertEqual(link?.note, "QCM Mobile") From 63391724d02f77d2ef2786410a11521d51667a21 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:22:36 -0300 Subject: [PATCH 05/58] Document local, iCloud and Files provider storage behavior --- iOS/README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/iOS/README.md b/iOS/README.md index f28cb35..95fb693 100644 --- a/iOS/README.md +++ b/iOS/README.md @@ -36,11 +36,40 @@ The project uses automatic signing but does not hard-code a development team so 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. + +### 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. + +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. 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 +94,45 @@ 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; open Drawer and verify it reports syncing, requests materialization, then recovers automatically when the file becomes current. +- [ ] Create or simulate an unresolved iCloud document conflict if practical; verify Drawer refuses canonical writes until the conflict is resolved. +- [ ] 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 keeps the bookmark/cache and reports provider recovery instead of false success. - [ ] 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. From efd7178ef435ded3e9ee5d42c3515b9096bb50dc Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:23:21 -0300 Subject: [PATCH 06/58] Define local and iCloud storage contract --- Docs/IOS.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Docs/IOS.md b/Docs/IOS.md index 9784c21..2db7067 100644 --- a/Docs/IOS.md +++ b/Docs/IOS.md @@ -117,6 +117,24 @@ 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`, retains the bookmark and last-known-good UI, 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. + +The foreground app automatically retries transient iCloud / File Provider availability with suspended `Task` delays; it never sleeps the main thread. Retry is cancelled when the scene backgrounds, the source changes, or a current canonical read succeeds. Authentication, permission loss, missing files, and unresolved conflicts are surfaced as actionable non-transient states rather than retry loops. + +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 a provider-offline/authentication failure preserves the bookmark and widget cache and fails closed. + ### Shared core Keep `TodoParser`, `TodoWriteback`, `TodoItem`, planning, timer models, and other deterministic behavior in `DrawerCore`. @@ -127,6 +145,8 @@ Add an iOS-compatible document boundary rather than teaching core logic about UI - 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. @@ -144,12 +164,13 @@ Apple’s iOS file model returns externally selected URLs through the document p - 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 - 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 selected canonical `Drawer.md` when the extension can resolve the security-scoped bookmark. If the File Provider is unavailable, iCloud is still materializing the 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,7 +185,7 @@ 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. From 5cffe200318bb7e21fcbac6c5d89d63694e897c4 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:24:03 -0300 Subject: [PATCH 07/58] Make local and iCloud source choice explicit --- iOS/DrawerMobile/Views/DrawerConnectionView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerConnectionView.swift b/iOS/DrawerMobile/Views/DrawerConnectionView.swift index dc9ba39..109e70f 100644 --- a/iOS/DrawerMobile/Views/DrawerConnectionView.swift +++ b/iOS/DrawerMobile/Views/DrawerConnectionView.swift @@ -24,7 +24,7 @@ struct DrawerConnectionView: View { 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.") + : "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.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -53,7 +53,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) From b2f331b152894ca5b5f8e8fd6e041de1f739ed57 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:26:14 -0300 Subject: [PATCH 08/58] Persist staged file selections across provider materialization --- iOS/Shared/DrawerShared.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iOS/Shared/DrawerShared.swift b/iOS/Shared/DrawerShared.swift index 820b8bf..a9420cf 100644 --- a/iOS/Shared/DrawerShared.swift +++ b/iOS/Shared/DrawerShared.swift @@ -3,6 +3,7 @@ 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" From b38a002593c61ebaa3d7abada0f283c4af9918ef Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:26:54 -0300 Subject: [PATCH 09/58] Stage cloud-backed file selections until canonical read succeeds --- iOS/Shared/DrawerBookmarkStore.swift | 127 +++++++++++++++++++++------ 1 file changed, 98 insertions(+), 29 deletions(-) diff --git a/iOS/Shared/DrawerBookmarkStore.swift b/iOS/Shared/DrawerBookmarkStore.swift index 479cf0c..e604b2a 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,77 @@ enum DrawerBookmarkError: LocalizedError { } } +enum DrawerBookmarkSaveOutcome: Equatable { + case ready + case waitingForProvider +} + 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 has not materialized the bytes yet, 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() } - } - - // 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 - ) + // A newer selection always supersedes an older pending attempt, but it + // never touches the last known-good primary bookmark until validated. + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) + let data = try makeBookmarkData(for: pickedURL) let probe = DrawerFileSession(url: pickedURL) - let contents = try probe.read() - guard String(data: contents, encoding: .utf8) != nil else { - throw DrawerBookmarkError.invalidEncoding - } - // 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) + do { + let contents = try probe.read() + guard String(data: contents, encoding: .utf8) != nil else { + throw DrawerBookmarkError.invalidEncoding + } + + DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) + return .ready + } catch let accessError as DrawerFileAccessError where accessError.isTransient { + DrawerShared.defaults.set(data, forKey: DrawerShared.pendingBookmarkKey) + return .waitingForProvider + } } 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 to materialize the new selection. + 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 +104,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 +129,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 + // materializing file contents. + 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 + ) + } } From 779aafa6cf684116446fa6847246fd45e4d8cc16 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:27:23 -0300 Subject: [PATCH 10/58] Preserve current pending source when replacement validation fails --- iOS/Shared/DrawerBookmarkStore.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iOS/Shared/DrawerBookmarkStore.swift b/iOS/Shared/DrawerBookmarkStore.swift index e604b2a..995a267 100644 --- a/iOS/Shared/DrawerBookmarkStore.swift +++ b/iOS/Shared/DrawerBookmarkStore.swift @@ -51,10 +51,6 @@ enum DrawerBookmarkStore { throw DrawerBookmarkError.appGroupUnavailable } - // A newer selection always supersedes an older pending attempt, but it - // never touches the last known-good primary bookmark until validated. - DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) - let data = try makeBookmarkData(for: pickedURL) let probe = DrawerFileSession(url: pickedURL) @@ -65,8 +61,12 @@ enum DrawerBookmarkStore { } DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) + DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) return .ready } catch let accessError as DrawerFileAccessError where accessError.isTransient { + // A newer viable-but-not-materialized 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 .waitingForProvider } From d70cf233910e0f43d251478a3d0dd7bbd50be402 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:29:14 -0300 Subject: [PATCH 11/58] Avoid retrying terminal File Provider errors --- iOS/Shared/DrawerFileSession.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/iOS/Shared/DrawerFileSession.swift b/iOS/Shared/DrawerFileSession.swift index 5ed6074..a9700a4 100644 --- a/iOS/Shared/DrawerFileSession.swift +++ b/iOS/Shared/DrawerFileSession.swift @@ -240,7 +240,11 @@ final class DrawerFileSession { case .noSuchItem: return .itemMissing default: - return .providerUnavailable(storage) + // 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 } } From e50f754ecf19ba494fcbe2af380b41edce4e860a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:31:15 -0300 Subject: [PATCH 12/58] Make cloud-backed source switching transactional --- .../Model/DrawerMobileModel.swift | 211 ++++++++++++++++-- 1 file changed, 196 insertions(+), 15 deletions(-) diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index b5001c6..257806a 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -9,6 +9,7 @@ final class DrawerMobileModel: ObservableObject { case loading case disconnected case connected + case waitingForProvider case needsPermission } @@ -32,11 +33,14 @@ final class DrawerMobileModel: ObservableObject { 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 hasTransientAccessFailure = false private var isSceneActive = true private var focusSessionID: UUID? @@ -54,6 +58,17 @@ final class DrawerMobileModel: ObservableObject { var connectedFileURL: URL? { document?.url } func bootstrap() { + if DrawerBookmarkStore.hasPendingBookmark { + // A staged cloud/provider selection may have been interrupted by a + // process kill. Reopen the previous canonical source first when one + // exists, then continue materializing the staged replacement. + if DrawerBookmarkStore.hasBookmark { + openStoredDocument() + } + beginPendingSelection() + return + } + guard DrawerBookmarkStore.hasBookmark else { WidgetInteractionFeedbackStore.clear() connectionState = .disconnected @@ -64,14 +79,20 @@ final class DrawerMobileModel: ObservableObject { func connect(to pickedURL: URL) { do { - try DrawerBookmarkStore.save(pickedURL) - openStoredDocument() + switch try DrawerBookmarkStore.save(pickedURL) { + case .ready: + openStoredDocument() + case .waitingForProvider: + 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. + // Change Drawer.md stays transactional even when another staged + // source already exists. A bad replacement never destroys the + // active source or the viable staged source that preceded it. if document != nil { connectionState = .connected + } else if pendingDocument != nil || DrawerBookmarkStore.hasPendingBookmark { + connectionState = .waitingForProvider } else { connectionState = DrawerBookmarkStore.hasBookmark ? .needsPermission : .disconnected } @@ -84,6 +105,7 @@ final class DrawerMobileModel: ObservableObject { document = nil clearUndo() cancelProviderRetry() + clearPendingRuntime() hasTransientAccessFailure = false lastAppliedData = nil lastAppliedDayKey = nil @@ -108,13 +130,20 @@ final class DrawerMobileModel: ObservableObject { if !active { persistFocusState() cancelProviderRetry() + cancelPendingRetry() } - guard let document else { return } - if active { - startObserving(document) - reload() - } else { - document.stopObserving() + + if let document { + if active { + startObserving(document) + reload() + } else { + document.stopObserving() + } + } + + if active, pendingDocument != nil { + attemptPendingSelection() } } @@ -136,9 +165,9 @@ final class DrawerMobileModel: ObservableObject { if base == lastAppliedData, today == lastAppliedDayKey { if hasTransientAccessFailure { hasTransientAccessFailure = false - statusMessage = nil } cancelProviderRetry() + statusMessage = pendingStatusMessage return } @@ -481,7 +510,7 @@ final class DrawerMobileModel: ObservableObject { } hasTransientAccessFailure = false cancelProviderRetry() - statusMessage = nil + statusMessage = pendingStatusMessage lastAppliedData = data lastAppliedDayKey = today publishWidgetSnapshot(data, today: today) @@ -507,6 +536,7 @@ final class DrawerMobileModel: ObservableObject { document?.stopObserving() clearUndo() cancelProviderRetry() + clearPendingRuntime() hasTransientAccessFailure = false document = newDocument sourceName = newDocument.url.lastPathComponent @@ -522,14 +552,115 @@ final class DrawerMobileModel: ObservableObject { } } + private func beginPendingSelection() { + do { + let candidate = CoordinatedDrawerDocument(session: try DrawerBookmarkStore.openPendingSession()) + pendingDocument = candidate + pendingStatusMessage = "Getting the new Drawer.md ready." + sourceName = document?.url.lastPathComponent ?? candidate.url.lastPathComponent + + if document == nil { + connectionState = .waitingForProvider + carriedItems = [] + todayItems = [] + upcomingItems = [] + backlogItems = [] + upcomingLabel = "" + } else { + connectionState = .connected + } + + 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 + } + + // Promotion happens only after a real current canonical read. Until + // this line the previous primary bookmark remains untouched. + try DrawerBookmarkStore.promotePending() + + document?.stopObserving() + clearUndo() + cancelProviderRetry() + cancelPendingRetry() + pendingStatusMessage = nil + hasTransientAccessFailure = false + document = candidate + pendingDocument = nil + sourceName = candidate.url.lastPathComponent + connectionState = .connected + lastAppliedData = nil + lastAppliedDayKey = nil + statusMessage = nil + if isSceneActive { startObserving(candidate) } + reload() + } catch let accessError as DrawerFileAccessError where accessError.isTransient { + pendingStatusMessage = pendingMessage(for: accessError) + statusMessage = pendingStatusMessage + if document == nil { + connectionState = .waitingForProvider + } else { + connectionState = .connected + } + schedulePendingRetry() + } catch { + handlePendingSelectionFailure(error) + } + } + + private func handlePendingSelectionFailure(_ error: Error) { + let detail = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + DrawerBookmarkStore.discardPending() + clearPendingRuntime() + + if document != nil { + connectionState = .connected + statusMessage = "Couldn't switch Drawer.md. \(detail) Your current file is still connected." + DrawerHaptics.shared.error() + return + } + + if DrawerBookmarkStore.hasBookmark { + openStoredDocument() + if document != nil { + statusMessage = "Couldn't switch Drawer.md. \(detail) Your previous file is still connected." + 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 .waitingForProvider: + self.beginPendingSelection() + } } catch { self.fail(error) } @@ -672,11 +803,61 @@ final class DrawerMobileModel: ObservableObject { } } + private func schedulePendingRetry() { + guard isSceneActive, pendingDocument != nil, pendingRetryTask == nil else { return } + + pendingRetryTask = Task { [weak self] in + let delays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + .seconds(3), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + .seconds(5), + ] + + for delay in delays { + 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 } + } + + self?.pendingRetryTask = nil + } + } + private func cancelProviderRetry() { providerRetryTask?.cancel() providerRetryTask = nil } + private func cancelPendingRetry() { + pendingRetryTask?.cancel() + pendingRetryTask = nil + } + + private func clearPendingRuntime() { + cancelPendingRetry() + pendingDocument = nil + pendingStatusMessage = nil + } + private func fail(_ error: Error) { let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription let changed = statusMessage != message From 936804cb755bf32bfd5dacf4e179b9f7d5f2f135 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:31:35 -0300 Subject: [PATCH 13/58] Show a dedicated provider-materialization state --- iOS/DrawerMobile/App/DrawerRootView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/iOS/DrawerMobile/App/DrawerRootView.swift b/iOS/DrawerMobile/App/DrawerRootView.swift index 68a013c..678c91d 100644 --- a/iOS/DrawerMobile/App/DrawerRootView.swift +++ b/iOS/DrawerMobile/App/DrawerRootView.swift @@ -26,9 +26,17 @@ struct DrawerRootView: View { 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 } ) From 653d930fc1c0db471ebdaa904a063470d748727c Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:31:50 -0300 Subject: [PATCH 14/58] Explain cloud materialization without implying reconnect --- .../Views/DrawerConnectionView.swift | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerConnectionView.swift b/iOS/DrawerMobile/Views/DrawerConnectionView.swift index 109e70f..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 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.") + 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) @@ -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" + } } From e1b382e30c62148c457b9ad4f272f57cf4a9b96a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:33:07 -0300 Subject: [PATCH 15/58] Preserve recoverable provider grants without false retries --- iOS/Shared/DrawerFileSession.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/iOS/Shared/DrawerFileSession.swift b/iOS/Shared/DrawerFileSession.swift index a9700a4..eb891fe 100644 --- a/iOS/Shared/DrawerFileSession.swift +++ b/iOS/Shared/DrawerFileSession.swift @@ -47,6 +47,7 @@ enum DrawerFileAccessError: LocalizedError { } } + /// Conditions that can heal by waiting while Drawer remains foregrounded. var isTransient: Bool { switch self { case .waitingForICloud, .providerUnavailable: @@ -56,6 +57,19 @@ enum DrawerFileAccessError: LocalizedError { } } + /// 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: From 02dff0308e872d062f2941244ecfae5b5dfecf59 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:33:30 -0300 Subject: [PATCH 16/58] Stage provider grants that can recover without repicking --- iOS/Shared/DrawerBookmarkStore.swift | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/iOS/Shared/DrawerBookmarkStore.swift b/iOS/Shared/DrawerBookmarkStore.swift index 995a267..1ff012b 100644 --- a/iOS/Shared/DrawerBookmarkStore.swift +++ b/iOS/Shared/DrawerBookmarkStore.swift @@ -25,7 +25,7 @@ enum DrawerBookmarkError: LocalizedError { enum DrawerBookmarkSaveOutcome: Equatable { case ready - case waitingForProvider + case staged } enum DrawerBookmarkStore { @@ -43,9 +43,10 @@ enum DrawerBookmarkStore { /// /// 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 has not materialized the bytes yet, keep the new bookmark in a - /// separate pending slot. The previous canonical bookmark remains intact - /// until the pending source produces a real successful read. + /// 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 @@ -63,12 +64,12 @@ enum DrawerBookmarkStore { DrawerShared.defaults.set(data, forKey: DrawerShared.bookmarkKey) DrawerShared.defaults.removeObject(forKey: DrawerShared.pendingBookmarkKey) return .ready - } catch let accessError as DrawerFileAccessError where accessError.isTransient { - // A newer viable-but-not-materialized selection supersedes an older - // pending attempt. Terminal validation failures never disturb the - // source (primary or pending) that was already in use. + } 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 .waitingForProvider + return .staged } } @@ -83,7 +84,8 @@ enum DrawerBookmarkStore { /// 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 to materialize the new selection. + /// 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 @@ -131,7 +133,7 @@ enum DrawerBookmarkStore { if stale { // This is the same logical selection, not a source replacement, so // refreshing bookmark bytes is safe even if the provider is still - // materializing file contents. + // making file contents available. if let refreshed = try? makeBookmarkData(for: url) { DrawerShared.defaults.set(refreshed, forKey: refreshKey) } From 1bf7a82e1969d92152ea2a285297d4cbdfa45213 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:34:45 -0300 Subject: [PATCH 17/58] Preserve staged provider selections through user recovery --- .../Model/DrawerMobileModel.swift | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index 257806a..1e15c95 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -61,7 +61,7 @@ final class DrawerMobileModel: ObservableObject { if DrawerBookmarkStore.hasPendingBookmark { // A staged cloud/provider selection may have been interrupted by a // process kill. Reopen the previous canonical source first when one - // exists, then continue materializing the staged replacement. + // exists, then continue validating the staged replacement. if DrawerBookmarkStore.hasBookmark { openStoredDocument() } @@ -82,7 +82,7 @@ final class DrawerMobileModel: ObservableObject { switch try DrawerBookmarkStore.save(pickedURL) { case .ready: openStoredDocument() - case .waitingForProvider: + case .staged: beginPendingSelection() } } catch { @@ -142,6 +142,10 @@ final class DrawerMobileModel: ObservableObject { } } + // Authentication/conflict states deliberately do not busy-poll. The + // user fixes those in Files/Obsidian/provider UI; becoming active is the + // natural retry point. Transient download/offline states also get an + // immediate attempt here before their foreground retry loop resumes. if active, pendingDocument != nil { attemptPendingSelection() } @@ -604,7 +608,7 @@ final class DrawerMobileModel: ObservableObject { statusMessage = nil if isSceneActive { startObserving(candidate) } reload() - } catch let accessError as DrawerFileAccessError where accessError.isTransient { + } catch let accessError as DrawerFileAccessError where accessError.preservesSelectedGrant { pendingStatusMessage = pendingMessage(for: accessError) statusMessage = pendingStatusMessage if document == nil { @@ -612,7 +616,15 @@ final class DrawerMobileModel: ObservableObject { } else { connectionState = .connected } - schedulePendingRetry() + + if accessError.isTransient { + schedulePendingRetry() + } else { + // Authentication and conflict states require user action. Keep + // the staged bookmark but avoid a pointless foreground poll; + // setSceneActive(true) retries when the user returns. + cancelPendingRetry() + } } catch { handlePendingSelectionFailure(error) } @@ -658,7 +670,7 @@ final class DrawerMobileModel: ObservableObject { switch try DrawerBookmarkStore.save(newURL) { case .ready: self.openStoredDocument() - case .waitingForProvider: + case .staged: self.beginPendingSelection() } } catch { @@ -767,22 +779,18 @@ final class DrawerMobileModel: ObservableObject { guard isSceneActive, document != nil, providerRetryTask == nil else { return } providerRetryTask = Task { [weak self] in - let delays: [Duration] = [ + let initialDelays: [Duration] = [ .milliseconds(500), .seconds(1), .seconds(2), .seconds(3), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), ] + var attempt = 0 + + while !Task.isCancelled { + let delay = attempt < initialDelays.count ? initialDelays[attempt] : .seconds(5) + attempt += 1 - for delay in delays { do { try await Task.sleep(for: delay) } catch { @@ -798,8 +806,6 @@ final class DrawerMobileModel: ObservableObject { self.reload() if !self.hasTransientAccessFailure { return } } - - self?.providerRetryTask = nil } } @@ -807,22 +813,18 @@ final class DrawerMobileModel: ObservableObject { guard isSceneActive, pendingDocument != nil, pendingRetryTask == nil else { return } pendingRetryTask = Task { [weak self] in - let delays: [Duration] = [ + let initialDelays: [Duration] = [ .milliseconds(500), .seconds(1), .seconds(2), .seconds(3), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), - .seconds(5), ] + var attempt = 0 + + while !Task.isCancelled { + let delay = attempt < initialDelays.count ? initialDelays[attempt] : .seconds(5) + attempt += 1 - for delay in delays { do { try await Task.sleep(for: delay) } catch { @@ -837,8 +839,6 @@ final class DrawerMobileModel: ObservableObject { self.attemptPendingSelection() if self.pendingDocument == nil { return } } - - self?.pendingRetryTask = nil } } From 3a779e78b489989a6a7878e76522534d6a0fdea9 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:36:03 -0300 Subject: [PATCH 18/58] Document staged cloud source promotion and rollback --- iOS/README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/iOS/README.md b/iOS/README.md index 95fb693..79deb29 100644 --- a/iOS/README.md +++ b/iOS/README.md @@ -30,9 +30,10 @@ 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. @@ -57,19 +58,23 @@ Drawer checks Apple's iCloud download state before every canonical read/write: - **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. +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, 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. 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. @@ -109,8 +114,11 @@ Simulator CI cannot prove File Provider grants, real Taptic Engine feel, lock-st - [ ] 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; open Drawer and verify it reports syncing, requests materialization, then recovers automatically when the file becomes current. -- [ ] Create or simulate an unresolved iCloud document conflict if practical; verify Drawer refuses canonical writes until the conflict is resolved. +- [ ] 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. @@ -124,7 +132,8 @@ Simulator CI cannot prove File Provider grants, real Taptic Engine feel, lock-st - [ ] Change Drawer.md to an unreadable/non-UTF-8 file and verify the previous good connection is retained and a useful error is shown. - [ ] 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 keeps the bookmark/cache and reports provider recovery instead of false success. +- [ ] 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. From 091c30e9226d4c52dbc22f8290c6d8ec8cc2fa72 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:36:52 -0300 Subject: [PATCH 19/58] Define staged source switching in the iOS contract --- Docs/IOS.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Docs/IOS.md b/Docs/IOS.md index 2db7067..0ee074c 100644 --- a/Docs/IOS.md +++ b/Docs/IOS.md @@ -129,11 +129,13 @@ Drawer does not own a cloud service and does not assume that Obsidian owns the f 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`, retains the bookmark and last-known-good UI, 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. +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. -The foreground app automatically retries transient iCloud / File Provider availability with suspended `Task` delays; it never sleeps the main thread. Retry is cancelled when the scene backgrounds, the source changes, or a current canonical read succeeds. Authentication, permission loss, missing files, and unresolved conflicts are surfaced as actionable non-transient states rather than retry loops. +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. -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 a provider-offline/authentication failure preserves the bookmark and widget cache and fails closed. +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 @@ -141,7 +143,8 @@ Keep `TodoParser`, `TodoWriteback`, `TodoItem`, planning, timer models, and othe 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 @@ -149,7 +152,7 @@ Add an iOS-compatible document boundary rather than teaching core logic about UI - 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. @@ -157,20 +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, iCloud is still materializing the 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. +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 { @@ -187,7 +192,7 @@ 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 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. From fa445d758b1f6127e6833af6b5f6637d2f789e40 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:37:53 -0300 Subject: [PATCH 20/58] Lock provider retry and grant-preservation semantics --- iOS/Tests/DrawerMobileSharedTests.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 6a1fefc..1a97af0 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -98,17 +98,31 @@ final class DrawerMobileSharedTests: XCTestCase { XCTAssertEqual(try session.read(), updated) } - func testProviderErrorsKeepUsefulRecoverySemantics() { - let transient = DrawerFileAccessError.providerUnavailable(.files) - XCTAssertTrue(transient.isTransient) + func testProviderErrorsDistinguishAutomaticRetryFromGrantPreservation() { + let unavailable = DrawerFileAccessError.providerUnavailable(.files) + XCTAssertTrue(unavailable.isTransient) + XCTAssertTrue(unavailable.preservesSelectedGrant) XCTAssertEqual( - transient.widgetMessage, + 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 testObsidianLinkStripsAlias() { From 6ffb124f8b4e3ab8c8644b07e7afe1018bfd96b4 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:56:28 -0300 Subject: [PATCH 21/58] Polish quick capture persistence --- iOS/DrawerMobile/Views/QuickCaptureBar.swift | 25 ++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/iOS/DrawerMobile/Views/QuickCaptureBar.swift b/iOS/DrawerMobile/Views/QuickCaptureBar.swift index f8a4c54..c7ffe3b 100644 --- a/iOS/DrawerMobile/Views/QuickCaptureBar.swift +++ b/iOS/DrawerMobile/Views/QuickCaptureBar.swift @@ -4,11 +4,15 @@ 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 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 +27,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)) @@ -60,7 +64,7 @@ struct QuickCaptureBar: View { in: Circle() ) } - .buttonStyle(TactileButtonStyle(pressedScale: 0.92)) + .buttonStyle(TactileButtonStyle(pressedScale: 0.92, pressedOpacity: 0.96)) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityLabel("Add task") } @@ -70,9 +74,9 @@ struct QuickCaptureBar: View { .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.primary.opacity(focused ? 0.12 : 0.055), lineWidth: focused ? 1 : 0.75) + .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) @@ -90,6 +94,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) } @@ -140,6 +145,7 @@ struct QuickCaptureBar: View { 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 +153,18 @@ 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 { + // A failed canonical write deliberately leaves the draft and + // destination untouched so retrying never means retyping. + focused = true } } From 42ebd63e6c2ac9c1a3efb4699658e7086fd6b4c6 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:57:01 -0300 Subject: [PATCH 22/58] Refine task row state interactions --- iOS/DrawerMobile/Views/MobileTaskRow.swift | 100 ++++++++++++++------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/iOS/DrawerMobile/Views/MobileTaskRow.swift b/iOS/DrawerMobile/Views/MobileTaskRow.swift index 5d038a3..96850e2 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,31 +207,32 @@ 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() } } - Button("Start Focus", systemImage: "timer") { - model.startFocus(on: item) - DrawerHaptics.shared.focusStarted() - } + Menu("Move", systemImage: "arrow.turn.down.right") { moveButton(.today) moveButton(.tomorrow) @@ -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 } From e2615a332339b323400d08ff02f4118d19c66d5e Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:57:45 -0300 Subject: [PATCH 23/58] Add focused small widget --- iOS/DrawerWidgets/DrawerWidget.swift | 115 ++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/iOS/DrawerWidgets/DrawerWidget.swift b/iOS/DrawerWidgets/DrawerWidget.swift index b42243a..fdf4697 100644 --- a/iOS/DrawerWidgets/DrawerWidget.swift +++ b/iOS/DrawerWidgets/DrawerWidget.swift @@ -23,16 +23,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 +75,7 @@ struct DrawerWidget: Widget { .configurationDisplayName("Drawer") .description("Your day, straight from Drawer.md.") .supportedFamilies([ + .systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, @@ -66,6 +91,8 @@ private struct DrawerWidgetView: View { var body: some View { switch family { + case .systemSmall: + smallWidget case .accessoryCircular: accessoryCircular case .accessoryRectangular: @@ -77,6 +104,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 From f31f3cf820fef2c30beeecef461725f9cd454446 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:58:50 -0300 Subject: [PATCH 24/58] Clarify focus completion semantics --- iOS/DrawerMobile/Views/FocusStrip.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/iOS/DrawerMobile/Views/FocusStrip.swift b/iOS/DrawerMobile/Views/FocusStrip.swift index dd1418e..3ed29b7 100644 --- a/iOS/DrawerMobile/Views/FocusStrip.swift +++ b/iOS/DrawerMobile/Views/FocusStrip.swift @@ -35,13 +35,18 @@ struct FocusStrip: View { Spacer(minLength: 8) if timer.phase == .finished { - Button("Done") { + Button("Close") { + // Finishing a timer is not proof that its Markdown task is + // complete. Keep the language honest: this only dismisses + // the finished Focus session. model.resetFocus() DrawerHaptics.shared.focusDismissed() + DrawerActionFeedbackCenter.announce("Focus session closed") } .font(.subheadline.weight(.bold)) .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) + .accessibilityHint("Closes the timer without changing the task") } else { Button { switch timer.phase { @@ -60,20 +65,22 @@ struct FocusStrip: View { .frame(width: 36, height: 36) .background(.quaternary.opacity(0.6), in: 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) } - .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) From 0060a94ac049c6ff3caf99d5e082ece1a5a22c7f Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:00:48 -0300 Subject: [PATCH 25/58] Align completed task affordances --- iOS/DrawerMobile/Views/MobileTaskRow.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/iOS/DrawerMobile/Views/MobileTaskRow.swift b/iOS/DrawerMobile/Views/MobileTaskRow.swift index 96850e2..f84fea1 100644 --- a/iOS/DrawerMobile/Views/MobileTaskRow.swift +++ b/iOS/DrawerMobile/Views/MobileTaskRow.swift @@ -231,13 +231,13 @@ struct MobileTaskRow: View { model.startFocus(on: item) DrawerHaptics.shared.focusStarted() } + Menu("Move", systemImage: "arrow.turn.down.right") { + moveButton(.today) + moveButton(.tomorrow) + moveButton(.backlog) + } } - 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() } From 590f5d0f09d678691d17a8dd04bbe96de8f76afa Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:01:06 -0300 Subject: [PATCH 26/58] Tune tactile press response --- iOS/DrawerMobile/Haptics/DrawerHaptics.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ) } From 0d8c0fb5141ba44172d8402d21cf0820e96064e7 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:02:16 -0300 Subject: [PATCH 27/58] Make cold-launch capture instant --- iOS/DrawerMobile/Views/QuickCaptureBar.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/iOS/DrawerMobile/Views/QuickCaptureBar.swift b/iOS/DrawerMobile/Views/QuickCaptureBar.swift index c7ffe3b..7360f92 100644 --- a/iOS/DrawerMobile/Views/QuickCaptureBar.swift +++ b/iOS/DrawerMobile/Views/QuickCaptureBar.swift @@ -6,6 +6,7 @@ struct QuickCaptureBar: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @SceneStorage("drawer.capture.draft.v1") private var text = "" @SceneStorage("drawer.capture.destination.v1") private var destinationRawValue = DrawerTaskDestination.today.rawValue + @SceneStorage("drawer.capture.handled-token.v1") private var handledCaptureToken = 0 @State private var actionFeedback: DrawerActionFeedbackPayload? @FocusState private var focused: Bool @@ -82,8 +83,9 @@ struct QuickCaptureBar: View { .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 } @@ -142,6 +144,14 @@ struct QuickCaptureBar: View { .accessibilityElement(children: .combine) } + private func handleCaptureRequest(_ token: Int) { + guard token > 0, token != handledCaptureToken else { return } + handledCaptureToken = token + // Dispatch one turn so a cold-launch deep link can create the field + // before asking the system to present the keyboard. + DispatchQueue.main.async { focused = true } + } + private func save() { let clean = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !clean.isEmpty else { return } From b37efd56067acefed6e65d774e8fa13361a3677e Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:02:52 -0300 Subject: [PATCH 28/58] Cover next widget ordering --- iOS/Tests/DrawerMobileSharedTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 1a97af0..466ce3f 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -40,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") From 0491261c2f51f904b704c56deae8cfaa2c265178 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:04:34 -0300 Subject: [PATCH 29/58] Fix capture token lifecycle --- iOS/DrawerMobile/Views/QuickCaptureBar.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/iOS/DrawerMobile/Views/QuickCaptureBar.swift b/iOS/DrawerMobile/Views/QuickCaptureBar.swift index 7360f92..ed0d6de 100644 --- a/iOS/DrawerMobile/Views/QuickCaptureBar.swift +++ b/iOS/DrawerMobile/Views/QuickCaptureBar.swift @@ -6,7 +6,7 @@ struct QuickCaptureBar: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @SceneStorage("drawer.capture.draft.v1") private var text = "" @SceneStorage("drawer.capture.destination.v1") private var destinationRawValue = DrawerTaskDestination.today.rawValue - @SceneStorage("drawer.capture.handled-token.v1") private var handledCaptureToken = 0 + @State private var handledCaptureToken = 0 @State private var actionFeedback: DrawerActionFeedbackPayload? @FocusState private var focused: Bool @@ -148,7 +148,9 @@ struct QuickCaptureBar: View { guard token > 0, token != handledCaptureToken else { return } handledCaptureToken = token // Dispatch one turn so a cold-launch deep link can create the field - // before asking the system to present the keyboard. + // before asking the system to present the keyboard. The handled token + // is intentionally in-memory: a new model process starts its token + // sequence over and must never collide with restored scene storage. DispatchQueue.main.async { focused = true } } From 8751313d41612d6802bebd5a2500d94eaf0cf242 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:07:57 -0300 Subject: [PATCH 30/58] Define shared Focus Live Activity state --- iOS/Shared/DrawerShared.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/iOS/Shared/DrawerShared.swift b/iOS/Shared/DrawerShared.swift index a9420cf..b08b745 100644 --- a/iOS/Shared/DrawerShared.swift +++ b/iOS/Shared/DrawerShared.swift @@ -1,3 +1,4 @@ +import ActivityKit import Foundation enum DrawerShared { @@ -33,6 +34,28 @@ 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 + let targetDuration: TimeInterval +} + enum DrawerFocusStore { static func load() -> DrawerPersistedFocus? { guard let data = DrawerShared.defaults.data(forKey: DrawerShared.focusSessionKey) else { return nil } From 0b83664e3a391802fd76562990148d4bce837787 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:08:58 -0300 Subject: [PATCH 31/58] Keep Live Activity state minimal --- iOS/Shared/DrawerShared.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOS/Shared/DrawerShared.swift b/iOS/Shared/DrawerShared.swift index b08b745..655997e 100644 --- a/iOS/Shared/DrawerShared.swift +++ b/iOS/Shared/DrawerShared.swift @@ -53,7 +53,6 @@ struct DrawerFocusActivityAttributes: ActivityAttributes { let sessionID: UUID let taskTitle: String - let targetDuration: TimeInterval } enum DrawerFocusStore { From ecccf09834e53508d50cbc5626c8b9b1411b235d Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:09:23 -0300 Subject: [PATCH 32/58] Keep Focus alive across iOS --- .../Focus/FocusNotificationScheduler.swift | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index 2412830..0ca187a 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -1,3 +1,4 @@ +import ActivityKit import Foundation import UserNotifications @@ -65,3 +66,137 @@ enum FocusNotificationScheduler { ) } } + +/// 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 + ) + } + } +} From 15123ecba54c0bf49607306a75085b61f902fbe2 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:09:45 -0300 Subject: [PATCH 33/58] Sync Focus with Live Activity --- iOS/DrawerMobile/Views/FocusStrip.swift | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/iOS/DrawerMobile/Views/FocusStrip.swift b/iOS/DrawerMobile/Views/FocusStrip.swift index 3ed29b7..59f7a54 100644 --- a/iOS/DrawerMobile/Views/FocusStrip.swift +++ b/iOS/DrawerMobile/Views/FocusStrip.swift @@ -36,10 +36,7 @@ struct FocusStrip: View { if timer.phase == .finished { Button("Close") { - // Finishing a timer is not proof that its Markdown task is - // complete. Keep the language honest: this only dismisses - // the finished Focus session. - model.resetFocus() + endFocus(completed: true) DrawerHaptics.shared.focusDismissed() DrawerActionFeedbackCenter.announce("Focus session closed") } @@ -59,6 +56,7 @@ struct FocusStrip: View { case .idle, .finished: break } + syncLiveActivity() } label: { Image(systemName: timer.phase == .running ? "pause.fill" : "play.fill") .font(.system(size: 14, weight: .bold)) @@ -69,7 +67,7 @@ struct FocusStrip: View { .accessibilityLabel(timer.phase == .running ? "Pause focus" : "Resume focus") Button { - model.resetFocus() + endFocus(completed: false) DrawerHaptics.shared.focusDismissed() DrawerActionFeedbackCenter.announce("Focus ended") } label: { @@ -92,5 +90,25 @@ struct FocusStrip: View { } .shadow(color: .black.opacity(0.035), radius: 12, y: 5) .accessibilityElement(children: .contain) + .task { syncLiveActivity() } + .onChange(of: timer.phase) { _, _ in syncLiveActivity() } + } + + private func syncLiveActivity() { + let focus = DrawerFocusStore.load() + Task { + await DrawerFocusLiveActivityManager.shared.reconcile(focus) + } + } + + private func endFocus(completed: Bool) { + let sessionID = DrawerFocusStore.load()?.id + model.resetFocus() + Task { + await DrawerFocusLiveActivityManager.shared.end( + sessionID: sessionID, + completed: completed + ) + } } } From ce8520af980b5c988be3bf903d2ff4816ccd46a4 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:10:44 -0300 Subject: [PATCH 34/58] Add Focus Live Activity UI --- iOS/DrawerWidgets/DrawerWidget.swift | 135 +++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/iOS/DrawerWidgets/DrawerWidget.swift b/iOS/DrawerWidgets/DrawerWidget.swift index fdf4697..11874ec 100644 --- a/iOS/DrawerWidgets/DrawerWidget.swift +++ b/iOS/DrawerWidgets/DrawerWidget.swift @@ -1,3 +1,4 @@ +import ActivityKit import AppIntents import SwiftUI import UIKit @@ -84,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 From f19dc106f72fb0421a3f17099f018d7169a1b6bd Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:10:54 -0300 Subject: [PATCH 35/58] Bundle Focus Live Activity --- iOS/DrawerWidgets/DrawerWidgetsBundle.swift | 1 + 1 file changed, 1 insertion(+) 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() } } From 9a6b58488ccdb1dcdf37539905d0365a0c90bc4a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:11:05 -0300 Subject: [PATCH 36/58] Enable Focus Live Activities --- iOS/DrawerMobile/Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) 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 From e9b56fe1ad4d0377ce5db6d649d994e3d850521a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:11:29 -0300 Subject: [PATCH 37/58] Cover Focus Live Activity state --- iOS/Tests/DrawerMobileSharedTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 466ce3f..6c81004 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -143,6 +143,20 @@ final class DrawerMobileSharedTests: XCTestCase { XCTAssertFalse(missing.preservesSelectedGrant) } + 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") From b147929d68c92d27e049d0b6c62ffe5bb2f01263 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:12:37 -0300 Subject: [PATCH 38/58] Start Live Activity from every Focus entry point --- iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index 0ca187a..c7546aa 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -6,6 +6,15 @@ enum FocusNotificationScheduler { private static let identifier = "drawer.focus.complete" static func schedule(taskTitle: String, seconds: TimeInterval) { + // startFocus/resume/restore all persist the canonical Focus session + // before scheduling its notification. Reconcile ActivityKit here so + // every entry point gets the same ambient state, including a Focus + // started from the full-screen routine surface. + let persistedFocus = DrawerFocusStore.load() + Task { + await DrawerFocusLiveActivityManager.shared.reconcile(persistedFocus) + } + guard seconds > 1 else { return } Task { let center = UNUserNotificationCenter.current() From 09538780a1f8059894fb043f27abb69952a43f53 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:45:43 -0300 Subject: [PATCH 39/58] Keep Live Activity in sync with Focus state --- .../Focus/FocusNotificationScheduler.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index c7546aa..bd7003a 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -10,10 +10,7 @@ enum FocusNotificationScheduler { // before scheduling its notification. Reconcile ActivityKit here so // every entry point gets the same ambient state, including a Focus // started from the full-screen routine surface. - let persistedFocus = DrawerFocusStore.load() - Task { - await DrawerFocusLiveActivityManager.shared.reconcile(persistedFocus) - } + reconcileLiveActivity() guard seconds > 1 else { return } Task { @@ -70,10 +67,22 @@ enum FocusNotificationScheduler { } static func cancel() { + // pauseFocus, completion, reset and stale-session cleanup all persist + // (or clear) DrawerFocusStore before calling cancel. Reconcile here so + // the Lock Screen / Dynamic Island never keeps counting after the app + // has paused, finished or dismissed the same session. + reconcileLiveActivity() UNUserNotificationCenter.current().removePendingNotificationRequests( withIdentifiers: [identifier] ) } + + private static func reconcileLiveActivity() { + let persistedFocus = DrawerFocusStore.load() + Task { + await DrawerFocusLiveActivityManager.shared.reconcile(persistedFocus) + } + } } /// Serializes ActivityKit mutations so rapid pause/resume taps cannot reorder From a9b6a3a673fe922575516bdc8aef6d6824f8f812 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:47:11 -0300 Subject: [PATCH 40/58] Harden mobile mutation and status semantics --- .../Model/DrawerMobileModel.swift | 174 ++++++++++++++++-- 1 file changed, 155 insertions(+), 19 deletions(-) diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index 1e15c95..5e32d5a 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -13,6 +13,12 @@ final class DrawerMobileModel: ObservableObject { case needsPermission } + enum StatusTone: Equatable { + case info + case warning + case error + } + struct UndoPayload { let label: String let originalData: Data @@ -26,7 +32,8 @@ 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 @@ -41,6 +48,7 @@ final class DrawerMobileModel: ObservableObject { 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? @@ -51,6 +59,11 @@ final class DrawerMobileModel: ObservableObject { FocusNotificationScheduler.cancel() DrawerHaptics.shared.focusFinished() self?.persistFocusState() + // persistFocusState happens after the timer flips to finished. Run a + // second reconciliation after persistence so ActivityKit receives + // the finished state even if the completion callback raced the + // scheduler's first read by a turn of the main actor. + FocusNotificationScheduler.cancel() } restoreFocusState() } @@ -100,6 +113,10 @@ final class DrawerMobileModel: ObservableObject { } } + func reportError(_ error: Error) { + fail(error) + } + func disconnect() { document?.stopObserving() document = nil @@ -114,7 +131,7 @@ final class DrawerMobileModel: ObservableObject { upcomingItems = [] backlogItems = [] upcomingLabel = "" - statusMessage = nil + clearStatus() DrawerBookmarkStore.clear() WidgetInteractionFeedbackStore.clear() if let snapshotURL = WidgetSnapshotStore.snapshotURL { @@ -171,7 +188,7 @@ final class DrawerMobileModel: ObservableObject { hasTransientAccessFailure = false } cancelProviderRetry() - statusMessage = pendingStatusMessage + restorePendingStatus() return } @@ -208,7 +225,10 @@ final class DrawerMobileModel: ObservableObject { // 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 } @@ -321,17 +341,69 @@ 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 } + /// Applies the editable task fields in one canonical transaction. A task's + /// identity includes its raw Markdown line, so the detail sheet dismisses + /// after this succeeds rather than continuing to mutate through a stale ID. + @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) @@ -358,7 +430,10 @@ 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 } @@ -373,7 +448,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 } @@ -384,7 +459,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 @@ -446,7 +521,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? { @@ -468,7 +546,7 @@ 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) if (error as? DrawerFileAccessError)?.isTransient != true { @@ -498,7 +576,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() @@ -514,7 +592,7 @@ final class DrawerMobileModel: ObservableObject { } hasTransientAccessFailure = false cancelProviderRetry() - statusMessage = pendingStatusMessage + restorePendingStatus() lastAppliedData = data lastAppliedDayKey = today publishWidgetSnapshot(data, today: today) @@ -526,7 +604,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" @@ -561,6 +642,7 @@ final class DrawerMobileModel: ObservableObject { 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 { @@ -572,6 +654,7 @@ final class DrawerMobileModel: ObservableObject { upcomingLabel = "" } else { connectionState = .connected + restorePendingStatus() } attemptPendingSelection() @@ -598,6 +681,7 @@ final class DrawerMobileModel: ObservableObject { cancelProviderRetry() cancelPendingRetry() pendingStatusMessage = nil + pendingStatusTone = .info hasTransientAccessFailure = false document = candidate pendingDocument = nil @@ -605,12 +689,13 @@ final class DrawerMobileModel: ObservableObject { connectionState = .connected lastAppliedData = nil lastAppliedDayKey = nil - statusMessage = nil + clearStatus() if isSceneActive { startObserving(candidate) } reload() } catch let accessError as DrawerFileAccessError where accessError.preservesSelectedGrant { pendingStatusMessage = pendingMessage(for: accessError) - statusMessage = pendingStatusMessage + pendingStatusTone = tone(for: accessError) + restorePendingStatus() if document == nil { connectionState = .waitingForProvider } else { @@ -637,7 +722,10 @@ final class DrawerMobileModel: ObservableObject { if document != nil { connectionState = .connected - statusMessage = "Couldn't switch Drawer.md. \(detail) Your current file is still connected." + setStatus( + "Couldn't switch Drawer.md. \(detail) Your current file is still connected.", + tone: .error + ) DrawerHaptics.shared.error() return } @@ -645,7 +733,10 @@ final class DrawerMobileModel: ObservableObject { if DrawerBookmarkStore.hasBookmark { openStoredDocument() if document != nil { - statusMessage = "Couldn't switch Drawer.md. \(detail) Your previous file is still connected." + setStatus( + "Couldn't switch Drawer.md. \(detail) Your previous file is still connected.", + tone: .error + ) DrawerHaptics.shared.error() return } @@ -700,10 +791,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 @@ -718,6 +811,7 @@ final class DrawerMobileModel: ObservableObject { FocusNotificationScheduler.cancel() case .finished: focusTimer.restoreFinished(taskTitle: saved.taskTitle) + persistFocusState() FocusNotificationScheduler.cancel() } } @@ -757,6 +851,21 @@ final class DrawerMobileModel: ObservableObject { ) } + private func armUndoIfExact(label: String, result: CommitResult) { + guard result.canonicalMatchesAttempt else { + // An external writer changed the file immediately after our write. + // The displayed canonical data already includes that writer's truth; + // a snapshot undo would erase it, so deliberately offer no undo. + 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) @@ -856,12 +965,14 @@ final class DrawerMobileModel: ObservableObject { cancelPendingRetry() pendingDocument = nil pendingStatusMessage = nil + pendingStatusTone = .info } private func fail(_ error: Error) { let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - let changed = statusMessage != message - statusMessage = message + let nextTone = tone(for: error) + let changed = statusMessage != message || statusTone != nextTone + setStatus(message, tone: nextTone) if let accessError = error as? DrawerFileAccessError { hasTransientAccessFailure = accessError.isTransient @@ -878,4 +989,29 @@ final class DrawerMobileModel: ObservableObject { 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) + } } From 9c73c8df56ef927ed104aa480095a1b628cba102 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:47:50 -0300 Subject: [PATCH 41/58] Route importer failures through typed status --- iOS/DrawerMobile/App/DrawerRootView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS/DrawerMobile/App/DrawerRootView.swift b/iOS/DrawerMobile/App/DrawerRootView.swift index 678c91d..6b5cf9a 100644 --- a/iOS/DrawerMobile/App/DrawerRootView.swift +++ b/iOS/DrawerMobile/App/DrawerRootView.swift @@ -21,6 +21,7 @@ struct DrawerRootView: View { case .loading: ProgressView() .controlSize(.large) + .accessibilityLabel("Opening Drawer") case .connected: DrawerHomeView( model: model, @@ -56,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 From e00db02f4575b51433a8d30bc1178b4ded870943 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:48:36 -0300 Subject: [PATCH 42/58] Polish home recovery and filtered states --- iOS/DrawerMobile/Views/DrawerHomeView.swift | 72 +++++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerHomeView.swift b/iOS/DrawerMobile/Views/DrawerHomeView.swift index 37ce9a5..a70832a 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") } @@ -97,6 +97,7 @@ struct DrawerHomeView: View { Text(Date.now.formatted(.dateTime.weekday(.wide))) .font(.system(size: 34, weight: .bold, design: .rounded)) .tracking(-1.1) + .minimumScaleFactor(0.82) Text(Date.now.formatted(.dateTime.month(.wide).day())) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) @@ -147,6 +148,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 +157,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 +166,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 +212,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 +224,49 @@ 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)) + .symbolEffect(.pulse, options: tone == .info ? .repeating.speed(0.25) : .nonRepeating) + 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] { @@ -266,6 +304,7 @@ private struct DrawerRoutineSession: View { Text(title) .font(.system(size: 30, weight: .bold, design: .rounded)) .tracking(-0.8) + .multilineTextAlignment(.center) Text("\(completedCount) of \(allItems.count)") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) @@ -300,7 +339,7 @@ private struct DrawerRoutineSession: View { Label("Focus", systemImage: "timer") .font(.headline) .frame(maxWidth: .infinity) - .frame(height: 52) + .frame(minHeight: 52) } .buttonStyle(.bordered) .buttonBorderShape(.roundedRectangle(radius: 16)) @@ -308,6 +347,10 @@ private struct DrawerRoutineSession: View { Button { if model.toggle(current) { DrawerHaptics.shared.taskCompleted() + DrawerActionFeedbackCenter.success( + "Completed \(current.title)", + systemImage: "checkmark.circle.fill" + ) if remaining.count == 1 { DrawerHaptics.shared.groupFinished() DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0.12 : 0.35)) { @@ -319,7 +362,7 @@ private struct DrawerRoutineSession: View { Label("Done", systemImage: "checkmark") .font(.headline) .frame(maxWidth: .infinity) - .frame(height: 52) + .frame(minHeight: 52) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 16)) @@ -336,6 +379,8 @@ private struct DrawerRoutineSession: View { Text("Done") .font(.title2.bold()) } + .accessibilityElement(children: .combine) + .accessibilityLabel("Routine complete") } Spacer() @@ -376,6 +421,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) } From 3935656de7fbdb8f5844351644f3f36ca66db67c Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:50:12 -0300 Subject: [PATCH 43/58] Add safe native task editing --- .../Views/DrawerTaskDetailSheet.swift | 213 +++++++++++++++++- 1 file changed, 205 insertions(+), 8 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift index 620664a..6a4bdeb 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(size: 27, weight: .bold, design: .rounded)) + .tracking(-0.5) + .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,166 @@ 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") + onSaved() + } + } +} From c23e27ecddadaf3399e5249e63c250e14d93afde Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:51:31 -0300 Subject: [PATCH 44/58] Order Focus completion persistence before reconciliation --- .../Model/DrawerMobileModel.swift | 47 ++----------------- 1 file changed, 5 insertions(+), 42 deletions(-) diff --git a/iOS/DrawerMobile/Model/DrawerMobileModel.swift b/iOS/DrawerMobile/Model/DrawerMobileModel.swift index 5e32d5a..4fd582d 100644 --- a/iOS/DrawerMobile/Model/DrawerMobileModel.swift +++ b/iOS/DrawerMobile/Model/DrawerMobileModel.swift @@ -56,14 +56,12 @@ final class DrawerMobileModel: ObservableObject { init() { focusTimer.onComplete = { [weak self] _ in - FocusNotificationScheduler.cancel() - DrawerHaptics.shared.focusFinished() + // 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() - // persistFocusState happens after the timer flips to finished. Run a - // second reconciliation after persistence so ActivityKit receives - // the finished state even if the completion callback raced the - // scheduler's first read by a turn of the main actor. FocusNotificationScheduler.cancel() + DrawerHaptics.shared.focusFinished() } restoreFocusState() } @@ -72,9 +70,6 @@ final class DrawerMobileModel: ObservableObject { func bootstrap() { if DrawerBookmarkStore.hasPendingBookmark { - // A staged cloud/provider selection may have been interrupted by a - // process kill. Reopen the previous canonical source first when one - // exists, then continue validating the staged replacement. if DrawerBookmarkStore.hasBookmark { openStoredDocument() } @@ -99,9 +94,6 @@ final class DrawerMobileModel: ObservableObject { beginPendingSelection() } } catch { - // Change Drawer.md stays transactional even when another staged - // source already exists. A bad replacement never destroys the - // active source or the viable staged source that preceded it. if document != nil { connectionState = .connected } else if pendingDocument != nil || DrawerBookmarkStore.hasPendingBookmark { @@ -159,10 +151,6 @@ final class DrawerMobileModel: ObservableObject { } } - // Authentication/conflict states deliberately do not busy-poll. The - // user fixes those in Files/Obsidian/provider UI; becoming active is the - // natural retry point. Transient download/offline states also get an - // immediate attempt here before their foreground retry loop resumes. if active, pendingDocument != nil { attemptPendingSelection() } @@ -192,10 +180,6 @@ final class DrawerMobileModel: ObservableObject { 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() @@ -221,9 +205,6 @@ 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 { setStatus( "Completed repeating occurrences stay in history. Edit the active copy instead.", @@ -353,9 +334,6 @@ final class DrawerMobileModel: ObservableObject { } != nil } - /// Applies the editable task fields in one canonical transaction. A task's - /// identity includes its raw Markdown line, so the detail sheet dismisses - /// after this succeeds rather than continuing to mutate through a stale ID. @discardableResult func updateTask( _ item: TodoItem, @@ -430,10 +408,7 @@ final class DrawerMobileModel: ObservableObject { ) }) else { return false } - armUndoIfExact( - label: "Moved to \(destination.title)", - result: result - ) + armUndoIfExact(label: "Moved to \(destination.title)", result: result) return true } @@ -563,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) @@ -672,8 +645,6 @@ final class DrawerMobileModel: ObservableObject { throw DrawerBookmarkError.invalidEncoding } - // Promotion happens only after a real current canonical read. Until - // this line the previous primary bookmark remains untouched. try DrawerBookmarkStore.promotePending() document?.stopObserving() @@ -705,9 +676,6 @@ final class DrawerMobileModel: ObservableObject { if accessError.isTransient { schedulePendingRetry() } else { - // Authentication and conflict states require user action. Keep - // the staged bookmark but avoid a pointless foreground poll; - // setSceneActive(true) retries when the user returns. cancelPendingRetry() } } catch { @@ -774,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() @@ -853,9 +819,6 @@ final class DrawerMobileModel: ObservableObject { private func armUndoIfExact(label: String, result: CommitResult) { guard result.canonicalMatchesAttempt else { - // An external writer changed the file immediately after our write. - // The displayed canonical data already includes that writer's truth; - // a snapshot undo would erase it, so deliberately offer no undo. clearUndo() return } From b8e82b7710ed9a36651d5e982258628e17d55168 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:52:16 -0300 Subject: [PATCH 45/58] Keep recovery status calm and deterministic --- iOS/DrawerMobile/Views/DrawerHomeView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOS/DrawerMobile/Views/DrawerHomeView.swift b/iOS/DrawerMobile/Views/DrawerHomeView.swift index a70832a..cdcbc33 100644 --- a/iOS/DrawerMobile/Views/DrawerHomeView.swift +++ b/iOS/DrawerMobile/Views/DrawerHomeView.swift @@ -228,7 +228,6 @@ struct DrawerHomeView: View { HStack(alignment: .top, spacing: 10) { Image(systemName: statusIcon(tone)) .foregroundStyle(statusStyle(tone)) - .symbolEffect(.pulse, options: tone == .info ? .repeating.speed(0.25) : .nonRepeating) Text(status) .font(.footnote) .foregroundStyle(.secondary) From c44b6d407d408b47717f34b61c850e2624c0270f Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:53:10 -0300 Subject: [PATCH 46/58] Close edited task surfaces deterministically --- iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift index 6a4bdeb..463a17b 100644 --- a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift +++ b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift @@ -544,7 +544,8 @@ private struct DrawerTaskEditSheet: View { if model.updateTask(item, title: cleanTitle, minutes: minutes, note: note) { DrawerHaptics.shared.saved() DrawerActionFeedbackCenter.success("Task updated", systemImage: "checkmark.circle.fill") - onSaved() + dismiss() + DispatchQueue.main.async(execute: onSaved) } } } From ba152c304e1c3aba84a2a30520f917a3224bf934 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:54:47 -0300 Subject: [PATCH 47/58] Make quick capture targets accessibility sized --- iOS/DrawerMobile/Views/QuickCaptureBar.swift | 26 +++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/iOS/DrawerMobile/Views/QuickCaptureBar.swift b/iOS/DrawerMobile/Views/QuickCaptureBar.swift index ed0d6de..61716a3 100644 --- a/iOS/DrawerMobile/Views/QuickCaptureBar.swift +++ b/iOS/DrawerMobile/Views/QuickCaptureBar.swift @@ -38,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()) } @@ -57,13 +57,14 @@ 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, pressedOpacity: 0.96)) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -72,9 +73,9 @@ struct QuickCaptureBar: View { .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) + RoundedRectangle(cornerRadius: 25, style: .continuous) .stroke(.primary.opacity(focused ? 0.13 : 0.055), lineWidth: focused ? 1 : 0.75) } .shadow(color: .black.opacity(focused ? 0.12 : 0.08), radius: focused ? 22 : 16, y: focused ? 9 : 7) @@ -105,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() { @@ -114,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) } @@ -131,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) } @@ -147,10 +151,6 @@ struct QuickCaptureBar: View { private func handleCaptureRequest(_ token: Int) { guard token > 0, token != handledCaptureToken else { return } handledCaptureToken = token - // Dispatch one turn so a cold-launch deep link can create the field - // before asking the system to present the keyboard. The handled token - // is intentionally in-memory: a new model process starts its token - // sequence over and must never collide with restored scene storage. DispatchQueue.main.async { focused = true } } @@ -174,8 +174,6 @@ struct QuickCaptureBar: View { focused = false } } else { - // A failed canonical write deliberately leaves the draft and - // destination untouched so retrying never means retyping. focused = true } } From 63a8fc3f67a3b0e7c05d67a6bb1bc5c420d8f27d Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:55:12 -0300 Subject: [PATCH 48/58] Simplify Focus strip and enlarge controls --- iOS/DrawerMobile/Views/FocusStrip.swift | 33 ++++++------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/iOS/DrawerMobile/Views/FocusStrip.swift b/iOS/DrawerMobile/Views/FocusStrip.swift index 59f7a54..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) @@ -36,13 +37,14 @@ struct FocusStrip: View { if timer.phase == .finished { Button("Close") { - endFocus(completed: true) + 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 { @@ -56,25 +58,26 @@ struct FocusStrip: View { case .idle, .finished: break } - syncLiveActivity() } 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, pressedOpacity: 0.96)) .accessibilityLabel(timer.phase == .running ? "Pause focus" : "Resume focus") Button { - endFocus(completed: false) + model.resetFocus() 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, pressedOpacity: 0.96)) .accessibilityLabel("End focus") @@ -90,25 +93,5 @@ struct FocusStrip: View { } .shadow(color: .black.opacity(0.035), radius: 12, y: 5) .accessibilityElement(children: .contain) - .task { syncLiveActivity() } - .onChange(of: timer.phase) { _, _ in syncLiveActivity() } - } - - private func syncLiveActivity() { - let focus = DrawerFocusStore.load() - Task { - await DrawerFocusLiveActivityManager.shared.reconcile(focus) - } - } - - private func endFocus(completed: Bool) { - let sessionID = DrawerFocusStore.load()?.id - model.resetFocus() - Task { - await DrawerFocusLiveActivityManager.shared.end( - sessionID: sessionID, - completed: completed - ) - } } } From e458bbcc9dc1d535f2c4b0c20d95e25880b2797a Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:57:30 -0300 Subject: [PATCH 49/58] Cover atomic mobile task edit semantics --- iOS/Tests/DrawerMobileSharedTests.swift | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/iOS/Tests/DrawerMobileSharedTests.swift b/iOS/Tests/DrawerMobileSharedTests.swift index 6c81004..1edada9 100644 --- a/iOS/Tests/DrawerMobileSharedTests.swift +++ b/iOS/Tests/DrawerMobileSharedTests.swift @@ -143,6 +143,42 @@ final class DrawerMobileSharedTests: XCTestCase { 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, From 42b40a02352fd92751087ca5c11f748cd12cafa4 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:59:22 -0300 Subject: [PATCH 50/58] Declare App Group UserDefaults privacy reason --- iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 From 2dba0aa4ed718ecf5dfe86043e559e37749f6dcc Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:59:50 -0300 Subject: [PATCH 51/58] Declare widget App Group privacy reason --- iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 From f106ab69ba8cad971a20912d8ac0078cb31e14a4 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:00:20 -0300 Subject: [PATCH 52/58] Strengthen iOS release metadata gate --- .github/workflows/ios.yml | 52 +++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) 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" From f7ece3a0dffaa55053706c6d31d7cb0fa00aeb3b Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:25 -0300 Subject: [PATCH 53/58] Make primary Drawer typography scale with Dynamic Type --- iOS/DrawerMobile/Views/DrawerHomeView.swift | 144 +++++++++++++------- 1 file changed, 91 insertions(+), 53 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerHomeView.swift b/iOS/DrawerMobile/Views/DrawerHomeView.swift index cdcbc33..6db89ac 100644 --- a/iOS/DrawerMobile/Views/DrawerHomeView.swift +++ b/iOS/DrawerMobile/Views/DrawerHomeView.swift @@ -92,31 +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) - .minimumScaleFactor(0.82) - 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) { @@ -301,8 +325,8 @@ 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)) @@ -317,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) @@ -330,41 +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(minHeight: 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() - DrawerActionFeedbackCenter.success( - "Completed \(current.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) + VStack(spacing: 10) { + routineFocusButton(current) + routineDoneButton(current) } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 16)) } } .padding(.horizontal, 28) @@ -402,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 { From fdb5881c171d8c739e4daac2476f6e04d633be5b Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:06:10 -0300 Subject: [PATCH 54/58] Scale task detail title with Dynamic Type --- iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift index 463a17b..b7338dc 100644 --- a/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift +++ b/iOS/DrawerMobile/Views/DrawerTaskDetailSheet.swift @@ -98,8 +98,8 @@ struct DrawerTaskDetailSheet: View { HStack(alignment: .top, spacing: 10) { Text(item.title) - .font(.system(size: 27, weight: .bold, design: .rounded)) - .tracking(-0.5) + .font(.system(.title2, design: .rounded, weight: .bold)) + .tracking(-0.3) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) From 9a2e47a7bf52a71e13ffb37e868e384fe72ed0e5 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:52:13 -0300 Subject: [PATCH 55/58] docs: lock iOS release acceptance and App Review path --- iOS/RELEASE.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 iOS/RELEASE.md 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. From 7353e9aa3b63d73356f246f17932cf1754c88ffa Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:54:39 -0300 Subject: [PATCH 56/58] fix: invalidate stale focus notification scheduling --- .../Focus/FocusNotificationScheduler.swift | 70 +++++++++++++++---- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index bd7003a..b6c82e6 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -2,38 +2,61 @@ 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) { - // startFocus/resume/restore all persist the canonical Focus session - // before scheduling its notification. Reconcile ActivityKit here so - // every entry point gets the same ambient state, including a Focus - // started from the full-screen routine surface. + generation &+= 1 + let scheduledGeneration = generation + let scheduledSessionID = DrawerFocusStore.load()?.id + reconcileLiveActivity() - guard seconds > 1 else { return } - Task { + 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" ) @@ -57,8 +80,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" ) @@ -67,16 +102,25 @@ enum FocusNotificationScheduler { } static func cancel() { - // pauseFocus, completion, reset and stale-session cleanup all persist - // (or clear) DrawerFocusStore before calling cancel. Reconcile here so - // the Lock Screen / Dynamic Island never keeps counting after the app - // has paused, finished or dismissed the same session. + 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 { From 25f30793d1c87f6d15c7b9b02848575c82f3da4c Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:56:12 -0300 Subject: [PATCH 57/58] fix: clear orphan focus surfaces on launch --- iOS/DrawerMobile/App/DrawerMobileApp.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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" { From eec8d69bf0a95b1ce8ade6b6d3bb0f335aa32bd2 Mon Sep 17 00:00:00 2001 From: Bassam <87358177+Bbrizly@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:57:46 -0300 Subject: [PATCH 58/58] fix: focus alerts --- iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift index b6c82e6..ad2a57b 100644 --- a/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift +++ b/iOS/DrawerMobile/Focus/FocusNotificationScheduler.swift @@ -14,6 +14,13 @@ enum FocusNotificationScheduler { 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 @@ -63,7 +70,6 @@ enum FocusNotificationScheduler { return } - center.removePendingNotificationRequests(withIdentifiers: [identifier]) let content = UNMutableNotificationContent() content.title = "Focus complete" content.body = taskTitle.isEmpty ? "Time's up." : taskTitle