commit 09301221000ff103601c578b10b7c1c3088e9d64
parent 1c46eb5e2e6ae720bcd912cb8494baccb4b2e21b
Author: Michael Camilleri <[email protected]>
Date: Wed, 25 Mar 2026 16:33:15 +0900
Use 'item' instead of 'task' in codebase
Diffstat:
80 files changed, 5216 insertions(+), 5216 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -5,15 +5,15 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
## Project Structure & Module Organization
- `Listless.xcodeproj` coordinates three app targets: "Listless iOS" (iPhone/iPad), "Listless macOS" (native Mac), and "Listless watchOS" (Apple Watch), all sharing code from the `Listless/` directory.
- `project.yml` defines the Xcode project structure for XcodeGen; run `xcodegen generate` to regenerate the project after modifying it.
-- `Listless/Models` owns `TaskItem` (NSManagedObject), `TaskStore` (plain `final class` wrapping Core Data operations), and Core Data model definitions; keep CloudKit configuration inside `Listless/Sync`.
-- `Listless/Extensions` holds extensions on shared types; `TaskListView+Logic.swift` and `TaskListView+SyncUI.swift` are extensions on `TaskListViewProtocol` (not the concrete struct) so SourceKit can resolve them unambiguously across both targets.
-- `Listless/Helpers` holds shared non-view supporting code (`AccentColor`, `KeyboardNavigationModifier`, `TaskListTypes`, `TaskListViewProtocol`). `AccentColor.swift` defines `ColorTheme` (cases: `pilbara`, `collaroy`) with HSB gradient stops and a cached color interpolation function (`cachedTaskColor`); both platforms read the selected theme from `@AppStorage("colorThemeRaw")`. `TaskListTypes.swift` defines the `FocusField` and `DragState` enums as top-level types (shared by both platform `TaskListView` structs) and the `FocusStateData` struct that manages selection state — including `inactiveSelections` for discontinuous selections created by Cmd+Click toggle on macOS. `TaskListViewProtocol.swift` defines the `@MainActor TaskListViewProtocol` that both structs conform to, declaring the shared property contract (`tasks`, `store`, `syncMonitor`, `managedObjectContext`, `focusedField`, `fState`, `dragState`, `draftPlacement`, `draftTitle`, `didStartDrag()`, `clearDraftTaskUI()`).
+- `Listless/Models` owns `ItemEntity` (NSManagedObject), `ItemStore` (plain `final class` wrapping Core Data operations), and Core Data model definitions; keep CloudKit configuration inside `Listless/Sync`.
+- `Listless/Extensions` holds extensions on shared types; `ItemListView+Logic.swift` and `ItemListView+SyncUI.swift` are extensions on `ItemListViewProtocol` (not the concrete struct) so SourceKit can resolve them unambiguously across both targets.
+- `Listless/Helpers` holds shared non-view supporting code (`AccentColor`, `KeyboardNavigationModifier`, `ItemListTypes`, `ItemListViewProtocol`). `AccentColor.swift` defines `ColorTheme` (cases: `pilbara`, `collaroy`) with HSB gradient stops and a cached color interpolation function (`cachedItemColor`); both platforms read the selected theme from `@AppStorage("colorThemeRaw")`. `ItemListTypes.swift` defines the `FocusField` and `DragState` enums as top-level types (shared by both platform `ItemListView` structs) and the `FocusStateData` struct that manages selection state — including `inactiveSelections` for discontinuous selections created by Cmd+Click toggle on macOS. `ItemListViewProtocol.swift` defines the `@MainActor ItemListViewProtocol` that both structs conform to, declaring the shared property contract (`items`, `store`, `syncMonitor`, `managedObjectContext`, `focusedField`, `fState`, `dragState`, `draftPlacement`, `draftTitle`, `didStartDrag()`, `clearDraftItemUI()`).
- `ListlessiOS/` contains the iOS app entry point, organised into three subdirectories:
- - `Views/` — iOS-specific view components (`TaskListView`, `TaskRowView`, `DraftRowView`, `PullToCreate`, `PullToClear`, `UndoToast`, `SettingsView`, `SyncDiagnosticsView`, `AboutView`).
- - `Helpers/` — gesture recognizers, UIKit representables, color definitions, and platform-shim view modifiers (`TappableTextField`, `TaskRowSwipeGesture`, `TaskRowDragGesture`, `AppColors`, `HoverCursorModifier`, etc.).
- - `Extensions/` — platform-specific extensions on shared types (`TaskListView+NavigationHeader`, `TaskListView+Toolbar`, `TaskListView+PullToCreate`, `TaskListView+PullToClear`, `TaskListView+PullGestures`, `TaskListView+Drag`, `TaskListView+Undo`).
-- `ListlessMac/` contains the macOS app entry point with the same three-subdirectory structure as `ListlessiOS/` (`Views/` includes `TaskListView`).
-- `ListlessWatch/` contains the watchOS app entry point and a simplified `Views/` subdirectory (`TaskListView`, `TaskRowView`). The watchOS target selectively includes only `Listless/Models`, `Listless/Sync`, and `Listless/Helpers/AccentColor.swift` — it does not use `TaskListViewProtocol` or the shared extensions. The watch app is read-only (no creating, editing, reordering, or deleting) and supports toggling task completion only.
+ - `Views/` — iOS-specific view components (`ItemListView`, `ItemRowView`, `DraftRowView`, `PullToCreate`, `PullToClear`, `UndoToast`, `SettingsView`, `SyncDiagnosticsView`, `AboutView`).
+ - `Helpers/` — gesture recognizers, UIKit representables, color definitions, and platform-shim view modifiers (`TappableTextField`, `ItemRowSwipeGesture`, `ItemRowDragGesture`, `AppColors`, `HoverCursorModifier`, etc.).
+ - `Extensions/` — platform-specific extensions on shared types (`ItemListView+NavigationHeader`, `ItemListView+Toolbar`, `ItemListView+PullToCreate`, `ItemListView+PullToClear`, `ItemListView+PullGestures`, `ItemListView+Drag`, `ItemListView+Undo`).
+- `ListlessMac/` contains the macOS app entry point with the same three-subdirectory structure as `ListlessiOS/` (`Views/` includes `ItemListView`).
+- `ListlessWatch/` contains the watchOS app entry point and a simplified `Views/` subdirectory (`ItemListView`, `ItemRowView`). The watchOS target selectively includes only `Listless/Models`, `Listless/Sync`, and `Listless/Helpers/AccentColor.swift` — it does not use `ItemListViewProtocol` or the shared extensions. The watch app is read-only (no creating, editing, reordering, or deleting) and supports toggling item completion only.
- `Tests/Unit` covers ordering, editing, and persistence; `Tests/Support` holds shared test helpers and fixtures.
## Build, Test, and Development Commands
@@ -52,16 +52,16 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
## Coding Style & Naming Conventions
- Use SwiftUI + Observation (`@Observable`), indent four spaces, and prefer trailing commas in builders.
-- Models are nouns (`TaskItem`), views end with `View`, and services end with `Service`; keep async methods verb-first (`syncTasks()`).
-- Centralize state in `@Observable TaskStore` that wraps Core Data operations; mutations flow through intent methods like `complete(taskID:)`.
-- `TaskStore.createTask(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the task before all existing active tasks. `createTask` does **not** save — callers must call `store.save()` explicitly when they want to persist. This keeps empty placeholder tasks (from background tap or Return) in-memory only until the user types, avoiding iCloud sync of transient objects.
-- Completed tasks display below active ones; never reorder or edit them in-place.
+- Models are nouns (`ItemEntity`), views end with `View`, and services end with `Service`; keep async methods verb-first (`syncItems()`).
+- Centralize state in `@Observable ItemStore` that wraps Core Data operations; mutations flow through intent methods like `complete(itemID:)`.
+- `ItemStore.createItem(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the item before all existing active items. `createItem` does **not** save — callers must call `store.save()` explicitly when they want to persist. This keeps empty placeholder items (from background tap or Return) in-memory only until the user types, avoiding iCloud sync of transient objects.
+- Completed items display below active ones; never reorder or edit them in-place.
- For selection state in ForEach contexts, use computed Bool values + callbacks rather than passing @Binding to children (avoids SwiftUI update issues).
-- The codebase uses "task" internally (e.g. `TaskItem`, `TaskStore`, `deleteTask`) but user-facing text (labels, toast messages, menu items) should use "item" instead.
+- Both code and user-facing text use "item" terminology. The Core Data entity name remains `TaskItem` (via `@objc(TaskItem)`) to avoid CloudKit migration, but the Swift class is `ItemEntity`.
## Sync & Data Guidelines
- Core Data with `NSPersistentCloudKitContainer` handles persistence and iCloud sync; configured in `PersistenceController` within `Listless/Sync`.
-- `TaskItem` is an `NSManagedObject` subclass with auto-updating `updatedAt` timestamp via `willSave()`.
+- `ItemEntity` is an `NSManagedObject` subclass (Core Data entity name: `TaskItem`, kept via `@objc(TaskItem)` to preserve CloudKit compatibility) with auto-updating `updatedAt` timestamp via `willSave()`.
- CloudKit container identifier is `iCloud.net.inqk.listless`; entitlements are defined in `project.yml` and generated by XcodeGen.
- Keep CloudKit configuration and Core Data setup inside `Listless/Sync`; exposing raw Core Data contexts elsewhere is discouraged.
- `KeyValueSyncBridge` (`Listless/Sync/KeyValueSyncBridge.swift`) bridges `@AppStorage` keys to `NSUbiquitousKeyValueStore` for iCloud sync of user preferences. Currently syncs `"headingText"` (the customisable list title). On startup it pulls cloud values into `UserDefaults`; bidirectional observation keeps the two stores in sync with an `isSyncing` flag to prevent feedback loops. To sync additional preferences, add the key to the `keys` set in `ListlessiOSApp.init()`.
@@ -72,8 +72,8 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
- Organize tests into `@Suite` groupings with descriptive names.
- Use natural function names that describe what's being tested (no `test` prefix required).
- Leverage parameterized tests with `@Test(arguments: [...])` for testing multiple scenarios.
-- Maintain ≥80% coverage for `TaskStore` and Core Data operations, especially reordering edge cases and merge conflict resolution.
-- UI tests must verify keyboard entry flows: pressing Return creates a task then focuses the new empty row; tapping whitespace also starts entry.
+- Maintain ≥80% coverage for `ItemStore` and Core Data operations, especially reordering edge cases and merge conflict resolution.
+- UI tests must verify keyboard entry flows: pressing Return creates an item then focuses the new empty row; tapping whitespace also starts entry.
- Use `PersistenceController(inMemory: true)` for isolated test environments with Core Data.
## macOS Implementation: SwiftUI vs AppKit
@@ -82,14 +82,14 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
## Menu Customisation (both platforms)
- **Do not use SwiftUI's `Commands` API** — broken on macOS 15 (dividers don't render, `CommandGroup(replacing:)` causes Window menu jitter) and on iPadOS (arrow key glyphs render as "?", `@FocusedValue` propagation fails when a UIKit view is first responder).
-- **Both platforms use native menu APIs** with a shared coordinator pattern: action closures + enabled-state booleans updated by `TaskListView.updateWindowCoordinator()` (macOS) / `TaskListView.updateMenuCoordinator()` (iOS).
+- **Both platforms use native menu APIs** with a shared coordinator pattern: action closures + enabled-state booleans updated by `ItemListView.updateWindowCoordinator()` (macOS) / `ItemListView.updateMenuCoordinator()` (iOS).
- **Adding a new key binding** requires four touch points per platform: (1) coordinator — add action closure + enabled flag, (2) selector/action protocol — add the `@objc` method, (3) menu definition — register the key command in the right menu, (4) `updateWindowCoordinator()`/`updateMenuCoordinator()` — wire up the action and enabled state.
### macOS
- **Menus are fully AppKit-owned** in `AppDelegate.installMainMenu()` (`ListlessMac/ListlessMacApp.swift`), assigned directly via `NSApp.mainMenu`.
- **No runtime menu patching**: do not use `NSMenu.didAddItemNotification`/tag-guard patch logic for command setup in the current architecture.
- **`WindowCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit. One instance per window, stored in `AppDelegate`'s `NSMapTable<NSWindow, WindowCoordinator>`. Handles menu actions/enabled state (surfaced via `NSMenuItemValidation.validateMenuItem`) and focus gating (see macOS focus gating below).
-- **Command shortcuts are canonical in AppKit menus** (e.g. New Item, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
+- **Command shortcuts are canonical in AppKit menus** (e.g. New Item, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `ItemListView.keyboardNavigation(...)`.
- **Selector style**: prefer typed `#selector(...)` where available; use `MenuSelectors` constants for string-based selectors that lack typed Swift symbols.
- **Window menu**: keep explicit baseline items in `installMainMenu()` and let `NSApp.windowsMenu` provide system-managed dynamic window list behavior.
- **Theme menu**: `installMainMenu()` builds a View > Theme submenu from `ColorTheme.displayOrder`; selection is persisted via `UserDefaults` key `"colorThemeRaw"` and handled by `handleThemeSelection(_:)` on `WindowCoordinator`.
@@ -97,29 +97,29 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
### iOS (iPad)
- **Menus use UIKit's `buildMenu(with:)`** in `IOSAppDelegate` (`ListlessiOS/ListlessiOSApp.swift`), inserting `UIKeyCommand` items into standard `.file` and `.edit` menus so the iPad keyboard shortcut overlay groups them correctly.
- **`IOSMenuCoordinator`** (`ListlessiOS/Helpers/AppCommands.swift`) bridges SwiftUI state to UIKit. `IOSMenuActions` protocol declares `@objc` action selectors; `KeyCaptureView` (the first responder in `KeyCommandBridge`) conforms and dispatches to the coordinator.
-- **Conditional availability**: `KeyCaptureView.canPerformAction(_:withSender:)` checks coordinator enabled flags; commands are greyed out in the overlay when conditions aren't met (e.g. Move Up disabled when selected task is first).
+- **Conditional availability**: `KeyCaptureView.canPerformAction(_:withSender:)` checks coordinator enabled flags; commands are greyed out in the overlay when conditions aren't met (e.g. Move Up disabled when selected item is first).
- **Plain (unmodified) key commands** (Up, Down, Space, Return, Delete) are still handled by `KeyCommandBridge`'s `keyCommands` property with `wantsPriorityOverSystemBehavior = true`. Command-modified shortcuts go through `buildMenu` → responder chain → `KeyCaptureView` action methods.
## iOS Implementation Notes
-- **Platform-specific inits**: iOS and macOS `TaskRowView` have diverged (iOS takes `isDragging: Binding<Bool>`). When adding new parameters, update both `TaskRowView` inits and both `TaskListView` bodies. The watchOS `TaskRowView` is independent and much simpler (tap-to-toggle only).
+- **Platform-specific inits**: iOS and macOS `ItemRowView` have diverged (iOS takes `isDragging: Binding<Bool>`). When adding new parameters, update both `ItemRowView` inits and both `ItemListView` bodies. The watchOS `ItemRowView` is independent and much simpler (tap-to-toggle only).
- **Appearance override**: Use `UIWindow.overrideUserInterfaceStyle` in `ListlessiOSApp`, not `.preferredColorScheme()` (which doesn't properly revert to nil in sheets).
- **Focus guard for sheets**: The `onChange(of: focusedFieldBinding)` handler skips "reclaim focus to `.scrollView`" logic when a sheet is presented, preventing focus theft from sheet TextFields.
- **App icon in About screen**: Use `Image("AboutIcon")` from `Media.xcassets/AboutIcon.imageset` — `.appiconset` images can't be loaded via `Image()` in SwiftUI.
-- **iOS color system**: `ListlessiOS/Helpers/AppColors.swift` defines `Color.outerBackground` and `Color.taskCard`. Adjust these two values to shift the palette.
-- **Pull-to-create/clear**: Scroll gesture handling is in `.pullGestures()` (`TaskListView+PullGestures.swift`); visual indicators are in `TaskListView+PullToCreate.swift` and `TaskListView+PullToClear.swift`. The macOS `body` omits all of these. The pull-to-create indicator and the draft prepend row are separate siblings in the VStack (not a ZStack overlay) — the indicator collapses to zero height when the draft row appears. `revealPhantomRow()` directly sets up draft placement and focus without calling `createNewTaskAtTop()`. Row spacing uses explicit `.padding(.bottom, rowGap)` on each row rather than VStack `spacing` (which is set to 0).
+- **iOS color system**: `ListlessiOS/Helpers/AppColors.swift` defines `Color.outerBackground` and `Color.itemCard`. Adjust these two values to shift the palette.
+- **Pull-to-create/clear**: Scroll gesture handling is in `.pullGestures()` (`ItemListView+PullGestures.swift`); visual indicators are in `ItemListView+PullToCreate.swift` and `ItemListView+PullToClear.swift`. The macOS `body` omits all of these. The pull-to-create indicator and the draft prepend row are separate siblings in the VStack (not a ZStack overlay) — the indicator collapses to zero height when the draft row appears. `revealPhantomRow()` directly sets up draft placement and focus without calling `createNewItemAtTop()`. Row spacing uses explicit `.padding(.bottom, rowGap)` on each row rather than VStack `spacing` (which is set to 0).
- **Haptics**: Gated behind `@AppStorage("hapticsEnabled")`. Pull-to-create/clear thresholds and drag-start use `.light` impact weight. Draft creation uses `.sensoryFeedback` on a `draftCount` trigger.
- **Liquid Glass**: The iOS settings button uses `.buttonStyle(.glass)` gated behind `if #available(iOS 26.0, *)`, falling back to `.buttonStyle(.plain)` on older versions.
-- **Selection on iOS/iPadOS is intentionally limited**: iOS supports single-task cursor navigation via arrow keys (for marking complete/incomplete) but does not support multi-select, Select All (Cmd+A), or Cmd+Click toggling. Full selection semantics (range select, multi-select, select all) are macOS-only. Do not add selection features to the iOS target.
+- **Selection on iOS/iPadOS is intentionally limited**: iOS supports single-item cursor navigation via arrow keys (for marking complete/incomplete) but does not support multi-select, Select All (Cmd+A), or Cmd+Click toggling. Full selection semantics (range select, multi-select, select all) are macOS-only. Do not add selection features to the iOS target.
## SwiftUI Implementation Notes
-- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`, `ListlessWatch/Views/TaskListView.swift`). iOS and macOS conform to `TaskListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `pState` (pull gestures, iOS only), `isDragging` (iOS only, separate `@State` to avoid dirtying `iState`), and `layoutStorage` (non-reactive `LayoutStorage` class holding `rowFrames` and `contentBottomY` — a plain class under `@State` so SwiftUI holds a stable reference without tracking field mutations). macOS uses `tState` (task/view-local) instead of `pState`/`isDragging`/`layoutStorage`. Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
-- **Selection pattern**: Parent owns `@State var selectedTaskID`; children receive `isSelected: Bool` + `onSelect: () -> Void` callback (avoids SwiftUI ForEach update issues with @Binding). macOS supports Cmd+Click to toggle individual items in/out of the selection, creating discontinuous selections tracked via `FocusStateData.inactiveSelections`. Shift+Arrow after Cmd+Click preserves inactive items and merges them when the active range becomes adjacent. Read modifiers via `NSApp.currentEvent?.modifierFlags` (not the `NSEvent.modifierFlags` class property) so CGEvent-based UI tests can set modifier flags on synthesised mouse events.
-- **Focus management**: Single `@FocusState` enum (`FocusField` in `TaskListTypes.swift`) with `.task(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state.
- - **iOS focus cleanup**: On iOS, always dismiss focus by setting `focusedField = nil`, never directly to `.scrollView`. The `onChange(of: focusedFieldBinding)` handler intercepts `nil` to run cleanup (e.g. `deleteIfEmpty` for empty tasks) before redirecting to `.scrollView`. Skipping `nil` desyncs SwiftUI focus state from UIKit first responder and can cause crashes.
- - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewTask()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches.
- - **macOS focus gating via `WindowCoordinator.allowedFocusTarget`**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. The per-window `allowedFocusTarget` property on `WindowCoordinator` prevents this: `doCommandBy` sets it to `.scrollView` before `makeFirstResponder(nil)`, and `ClickableNSTextField.acceptsFirstResponder` checks it — rejecting focus unless the field matches the allowed target. Each `ClickableNSTextField` carries a `taskID` for this comparison. The gate is cleared in `onChange(of: focusedFieldBinding)` once `newValue` is non-nil (reconciliation complete). When `pendingFocus` is set (e.g. revealing a draft row), the gate is updated to allow that specific target, and `ClickableNSTextField.viewDidMoveToWindow()` claims focus when the view enters the window hierarchy (handles the case where the NSView doesn't exist yet when focus is requested).
+- **ItemListView architecture**: Declared separately per platform (`ListlessiOS/Views/ItemListView.swift`, `ListlessMac/Views/ItemListView.swift`, `ListlessWatch/Views/ItemListView.swift`). iOS and macOS conform to `ItemListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `pState` (pull gestures, iOS only), `isDragging` (iOS only, separate `@State` to avoid dirtying `iState`), and `layoutStorage` (non-reactive `LayoutStorage` class holding `rowFrames` and `contentBottomY` — a plain class under `@State` so SwiftUI holds a stable reference without tracking field mutations). macOS uses `tState` (item/view-local) instead of `pState`/`isDragging`/`layoutStorage`. Shared logic lives in `ItemListView+Logic.swift` as an extension on `ItemListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
+- **Selection pattern**: Parent owns `@State var selectedItemID`; children receive `isSelected: Bool` + `onSelect: () -> Void` callback (avoids SwiftUI ForEach update issues with @Binding). macOS supports Cmd+Click to toggle individual items in/out of the selection, creating discontinuous selections tracked via `FocusStateData.inactiveSelections`. Shift+Arrow after Cmd+Click preserves inactive items and merges them when the active range becomes adjacent. Read modifiers via `NSApp.currentEvent?.modifierFlags` (not the `NSEvent.modifierFlags` class property) so CGEvent-based UI tests can set modifier flags on synthesised mouse events.
+- **Focus management**: Single `@FocusState` enum (`FocusField` in `ItemListTypes.swift`) with `.item(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state.
+ - **iOS focus cleanup**: On iOS, always dismiss focus by setting `focusedField = nil`, never directly to `.scrollView`. The `onChange(of: focusedFieldBinding)` handler intercepts `nil` to run cleanup (e.g. `deleteIfEmpty` for empty items) before redirecting to `.scrollView`. Skipping `nil` desyncs SwiftUI focus state from UIKit first responder and can cause crashes.
+ - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewItem()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches.
+ - **macOS focus gating via `WindowCoordinator.allowedFocusTarget`**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. The per-window `allowedFocusTarget` property on `WindowCoordinator` prevents this: `doCommandBy` sets it to `.scrollView` before `makeFirstResponder(nil)`, and `ClickableNSTextField.acceptsFirstResponder` checks it — rejecting focus unless the field matches the allowed target. Each `ClickableNSTextField` carries an `itemID` for this comparison. The gate is cleared in `onChange(of: focusedFieldBinding)` once `newValue` is non-nil (reconciliation complete). When `pendingFocus` is set (e.g. revealing a draft row), the gate is updated to allow that specific target, and `ClickableNSTextField.viewDidMoveToWindow()` claims focus when the view enters the window hierarchy (handles the case where the NSView doesn't exist yet when focus is requested).
- **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`. On iOS, `TappableTextField` defers `isDragging`-driven changes to `isEditable`/`isSelectable` via the coordinator's `setDragging(_:)` method (called from `Task { @MainActor in }` in `updateUIView`) — setting these properties synchronously in `updateUIView` causes UITextView to call `invalidateIntrinsicContentSize()`, creating a layout-to-state backward edge that triggers AttributeGraph cycle warnings.
-- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `UIGestureRecognizerRepresentable` (`TaskRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`TaskRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection.
+- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveItem()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `UIGestureRecognizerRepresentable` (`ItemRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`ItemRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection.
- **Keyboard navigation**: macOS uses dictionary-based keybindings in `KeyboardNavigationModifier.swift`. iOS uses `KeyCommandBridge` (UIViewRepresentable) because iPadOS `@FocusState` silently fails with hardware keyboards — the iOS `body` does **not** use `.focusable()`, `.focused(equals: .scrollView)`, or `.keyboardNavigation()`.
- Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar).
- **Escape hatches** (`GeometryReader`, `PreferenceKey`, `UIViewRepresentable`/`NSViewRepresentable`): only after exhausting SwiftUI-native alternatives (`.frame`, `.overlay`, `.background`, `alignmentGuide`).
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -7,104 +7,104 @@
objects = {
/* Begin PBXBuildFile section */
- 03BC4562430EC105221AB43B /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D1AF0DE30657AAD86482CA /* TaskRowView.swift */; };
060436CDDB388BC04C51581A /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E995954787F0A14CCFF348 /* AboutView.swift */; };
+ 066E095075895DE73F217816 /* ItemStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */; };
072594B24D88AD6D1DF7AFE5 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C611E04943F1D82D6F975592 /* SettingsView.swift */; };
- 07E2BB2FF9E75A922C3756AB /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DEECB5DC31DE5F34FB55A3 /* TaskListView.swift */; };
- 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; };
0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */; };
11AA75BE98CFBE44AEAB7100 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
+ 12384AB44B4578E19EF8B0B7 /* ItemStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47892E3231D64596A2A00105 /* ItemStoreTests.swift */; };
12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */; };
- 1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; };
+ 12FC79D42A110E2CDC753CCE /* ItemEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138DCA35ED82A745E4745175 /* ItemEntity.swift */; };
172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
- 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; };
+ 194CAF0FBC308BE96CE8AA7B /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3048ACA1CAF1284F99E1400E /* ItemRowView.swift */; };
19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658295C1386BFF48CE3C2419 /* UndoToast.swift */; };
1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
+ 1E31935122C5E97907B30C70 /* ItemStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47892E3231D64596A2A00105 /* ItemStoreTests.swift */; };
+ 20D2A3E4694AE35AE6CF4AAD /* ItemListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BFCBD4DCB35CC1C8F9401 /* ItemListView+PullGestures.swift */; };
+ 2323CE015C01C6354C557F90 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F80F7BB632A04A687890F0 /* ItemListView.swift */; };
239F975836FD432A5FF04036 /* ListlessiOSUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */; };
264BD64C1DD30376E8BDAF79 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
- 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
- 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */; };
- 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */; };
+ 2921A4A343D43664F954B588 /* ItemListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */; };
2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
- 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; };
- 354687FCBBCC2432340BD5EB /* TaskCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2143744C421C67E53A2674CF /* TaskCardModifier.swift */; };
- 365FDEE6823D7A114F3FB12A /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; };
+ 2F5309630692E89276CC3149 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */; };
+ 3383645AE13E9C3AAECFBD0B /* ItemListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */; };
37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; };
- 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
- 3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; };
3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */; };
+ 4263E6472020564AA702D117 /* ItemCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */; };
+ 47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
4A0682E85B50DF42ECF83B48 /* Listless watchOS.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C6812E535A24C599C28F9278 /* Listless watchOS.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */; };
- 4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; };
- 5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */; };
53700EA974FE4AD771FE89EC /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
543C8A0C8A9E2F77B2C0060F /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
- 54FCE72978F20AA64855B521 /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; };
568635BB34CD7EBE24E66A15 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */; };
+ 5716CA28163DE0EAAA875AEE /* ItemListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78232E9D9F77FA3630F9D089 /* ItemListView+PullToCreate.swift */; };
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; };
- 57E864F71C9C84B63A28E14F /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
- 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */; };
5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */; };
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
- 642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; };
+ 620D9398218A88B7E4C2331C /* ItemStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */; };
+ 63C08E89303BD17601271D2C /* ItemListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */; };
65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */; };
- 684A80B692237B74EDF3C7A8 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
- 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; };
77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; };
+ 790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
7BB45276D4EB96B8425D2EBD /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4DB90B3400C191460F4F4BD /* AppColors.swift */; };
- 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; };
- 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; };
- 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; };
- 879608140FA8D7A32078C3CF /* TaskListView+Undo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */; };
+ 84B31DA3CF21D57742C9217A /* ItemStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */; };
+ 851F46417FE40D6BC765BC70 /* ItemListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */; };
882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
- 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; };
- 8FA8398315B67F12EA25FA35 /* TaskStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */; };
- 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
- 93275BD83342D6CE94272E6A /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; };
+ 89C4109374BD64464B0018B7 /* ItemListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CC030045617DBC90812D79 /* ItemListView+NavigationHeader.swift */; };
+ 8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7CB071709951F52C7742A3 /* ItemRowView.swift */; };
+ 9082E96001188E516B7F903B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3116A37F1353BF6E18308DD2 /* ItemListView.swift */; };
+ 90BC899E66B98517A91F2627 /* ItemListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4ED76F5996A4308D2BC7C8 /* ItemListView+Drag.swift */; };
+ 930AE396D982D7C46E498311 /* ItemEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138DCA35ED82A745E4745175 /* ItemEntity.swift */; };
96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
- 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; };
99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
+ 9E75E4AEEF577E0096E22DBA /* ItemListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03343E3D48A0EF2B146528E2 /* ItemListView+Toolbar.swift */; };
A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
A10BF9D0C850105E4FA1A2AD /* CloudKitErrorClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */; };
A119D0130DB77E30FBCB5436 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
+ A34F23F6314067EFB35FB209 /* ItemListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */; };
A8D5A7B0DFBEC87501FD0526 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
+ A92F8503815056D3183CD7AE /* ItemListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */; };
+ B0F8B09E2AB38C8C4AF74C10 /* ItemRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */; };
B7CFDCA5EA48EDE1C768FA21 /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C79ABB39A40D3E1828716C7 /* AppCommands.swift */; };
BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
- C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; };
- C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+ BB16A28C4DA1695B722B45B2 /* ItemEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138DCA35ED82A745E4745175 /* ItemEntity.swift */; };
+ BDA8D53342F745B27B72B242 /* ItemRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A80C91DBDA44C879958098 /* ItemRowDragGesture.swift */; };
+ C7C69D883B45F1B4CE979AF7 /* ItemListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */; };
+ C89D4C17F91AF91F18B6EF4E /* ItemStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */; };
CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */; };
CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
- CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
- CC99A96BBC089C423F582E4F /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; };
- CDE538A0B3CA14B1CB421ED8 /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
+ D24133A6C0105FE8E4528EF2 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
+ D9325B2D23FF2CD644A1A7E3 /* ItemRowMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */; };
+ DAEA21531CFEA94D335FFC6E /* ItemStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */; };
+ DB0CBB6C3EE56406AF86FDE3 /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DF205300C6B51A53B256D6 /* ItemRowView.swift */; };
DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; };
+ DBFE5DADCAAF26CF77245410 /* ItemListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */; };
+ DC103B1BFDC5940F63DD48ED /* ItemRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AB46824DCDD04903EA4C82 /* ItemRowDragGesture.swift */; };
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; };
+ E0BDC0FCAB43CEE0C9AC5279 /* ItemListView+Undo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4B98BD127A56CE4669DCD5 /* ItemListView+Undo.swift */; };
E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
- E14F8177232BBC75FEEE1E2C /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
E2FC6CF95A2C59CA147172EE /* FPSOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */; };
E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; };
E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
- E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */; };
+ E627F653B5A692B42A75FFBA /* ItemListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 869254E16B52F36616416DB4 /* ItemListView+Toolbar.swift */; };
EA5E6FC7D61E235B70A139FA /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
- ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
F046B5A7F4C0BD7AAF46A69C /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
+ F2845953BA77F171FDA2A59F /* ItemStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */; };
F3DD1F167E4107456473B6B2 /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = 3DD31E8962F7EEC22EFC0CA9 /* Credits.html */; };
- F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */; };
+ F56B03B24F33B31394D91512 /* ItemListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */; };
F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
- FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632DA39B24C4CF1528A1A24D /* TaskListView.swift */; };
- FF58A3AB2BCFFA42BF2F6413 /* TaskRowMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2EF927F09D67532F44BB80D /* TaskRowMetrics.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -161,91 +161,91 @@
/* Begin PBXFileReference section */
01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
- 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; };
+ 03343E3D48A0EF2B146528E2 /* ItemListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Toolbar.swift"; sourceTree = "<group>"; };
+ 03A80C91DBDA44C879958098 /* ItemRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowDragGesture.swift; sourceTree = "<group>"; };
+ 05AB46824DCDD04903EA4C82 /* ItemRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowDragGesture.swift; sourceTree = "<group>"; };
0B750F1634E250256AF3FEB6 /* Listless iOS UI Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreCompletionTests.swift; sourceTree = "<group>"; };
126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 138DCA35ED82A745E4745175 /* ItemEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEntity.swift; sourceTree = "<group>"; };
17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColor.swift; sourceTree = "<group>"; };
- 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
- 2143744C421C67E53A2674CF /* TaskCardModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCardModifier.swift; sourceTree = "<group>"; };
- 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
- 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreOrderingTests.swift; sourceTree = "<group>"; };
+ 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
+ 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToClear.swift"; sourceTree = "<group>"; };
+ 2B8EC97702E7B218A03B7898 /* ItemStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStore.swift; sourceTree = "<group>"; };
+ 3048ACA1CAF1284F99E1400E /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
+ 3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 37F80F7BB632A04A687890F0 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
3DD31E8962F7EEC22EFC0CA9 /* Credits.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = Credits.html; sourceTree = "<group>"; };
- 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Drag.swift"; sourceTree = "<group>"; };
4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
+ 47892E3231D64596A2A00105 /* ItemStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreTests.swift; sourceTree = "<group>"; };
4C79ABB39A40D3E1828716C7 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
+ 4C7CB071709951F52C7742A3 /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
+ 4D4ED76F5996A4308D2BC7C8 /* ItemListView+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Drag.swift"; sourceTree = "<group>"; };
4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifierTests.swift; sourceTree = "<group>"; };
+ 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreEdgeCaseTests.swift; sourceTree = "<group>"; };
567DBAC2A39FA2760D006AAB /* PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToClear.swift; sourceTree = "<group>"; };
- 56DEECB5DC31DE5F34FB55A3 /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
- 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToClear.swift"; sourceTree = "<group>"; };
5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
- 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; };
- 632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
658295C1386BFF48CE3C2419 /* UndoToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UndoToast.swift; sourceTree = "<group>"; };
67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftRowView.swift; sourceTree = "<group>"; };
68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifier.swift; sourceTree = "<group>"; };
6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessWatchApp.swift; sourceTree = "<group>"; };
+ 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCardModifier.swift; sourceTree = "<group>"; };
+ 6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreOrderingTests.swift; sourceTree = "<group>"; };
6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncMonitor.swift; sourceTree = "<group>"; };
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; };
- 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListTypes.swift; sourceTree = "<group>"; };
- 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
- 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Logic.swift"; sourceTree = "<group>"; };
72BAC76C5B5ED291048705CF /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
- 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+SyncUI.swift"; sourceTree = "<group>"; };
+ 78232E9D9F77FA3630F9D089 /* ItemListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToCreate.swift"; sourceTree = "<group>"; };
7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
+ 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Logic.swift"; sourceTree = "<group>"; };
+ 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListTypes.swift; sourceTree = "<group>"; };
+ 869254E16B52F36616416DB4 /* ItemListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Toolbar.swift"; sourceTree = "<group>"; };
88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSUITests.swift; sourceTree = "<group>"; };
- 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Undo.swift"; sourceTree = "<group>"; };
9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
- 92D1AF0DE30657AAD86482CA /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
+ 93CC030045617DBC90812D79 /* ItemListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+NavigationHeader.swift"; sourceTree = "<group>"; };
9404C09EE1A4D91DFF338464 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
- 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; };
- 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; };
- 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreEdgeCaseTests.swift; sourceTree = "<group>"; };
9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
- A2EF927F09D67532F44BB80D /* TaskRowMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowMetrics.swift; sourceTree = "<group>"; };
AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
B88DC6E36FA41DCB6CEB9647 /* Listless macOS Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueSyncBridge.swift; sourceTree = "<group>"; };
BC845482926A73B0BF820328 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; };
+ C0DF205300C6B51A53B256D6 /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
C4DB90B3400C191460F4F4BD /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
C611E04943F1D82D6F975592 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C6812E535A24C599C28F9278 /* Listless watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
- CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
- D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListViewProtocol.swift; sourceTree = "<group>"; };
- D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullGestures.swift"; sourceTree = "<group>"; };
D3E995954787F0A14CCFF348 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
D43D37CE25806380C0B13466 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
- D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPSOverlay.swift; sourceTree = "<group>"; };
- DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
- E06485DBE35B60868E14202A /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
+ DB4B98BD127A56CE4669DCD5 /* ItemListView+Undo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Undo.swift"; sourceTree = "<group>"; };
+ DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowSwipeGesture.swift; sourceTree = "<group>"; };
+ E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+SyncUI.swift"; sourceTree = "<group>"; };
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
- E51B8129962E5CC78ECDDC2B /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
+ E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowMetrics.swift; sourceTree = "<group>"; };
E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+ E85BFCBD4DCB35CC1C8F9401 /* ItemListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullGestures.swift"; sourceTree = "<group>"; };
E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacUITests.swift; sourceTree = "<group>"; };
F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
- F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
+ F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListViewProtocol.swift; sourceTree = "<group>"; };
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
- FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -256,14 +256,14 @@
46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */,
DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */,
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */,
+ 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */,
+ 05AB46824DCDD04903EA4C82 /* ItemRowDragGesture.swift */,
+ E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */,
+ DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */,
E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */,
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
- 2143744C421C67E53A2674CF /* TaskCardModifier.swift */,
- D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */,
- A2EF927F09D67532F44BB80D /* TaskRowMetrics.swift */,
- 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -311,9 +311,9 @@
F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */,
D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */,
9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */,
+ 03A80C91DBDA44C879958098 /* ItemRowDragGesture.swift */,
466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */,
E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */,
- F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -342,12 +342,12 @@
children = (
D3E995954787F0A14CCFF348 /* AboutView.swift */,
67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */,
+ 3116A37F1353BF6E18308DD2 /* ItemListView.swift */,
+ C0DF205300C6B51A53B256D6 /* ItemRowView.swift */,
567DBAC2A39FA2760D006AAB /* PullToClear.swift */,
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
C611E04943F1D82D6F975592 /* SettingsView.swift */,
E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */,
- E51B8129962E5CC78ECDDC2B /* TaskListView.swift */,
- 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */,
658295C1386BFF48CE3C2419 /* UndoToast.swift */,
);
path = Views;
@@ -357,9 +357,9 @@
isa = PBXGroup;
children = (
17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */,
+ 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */,
+ F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */,
4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */,
- 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */,
- D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -367,13 +367,13 @@
8629C1C94770B3B0D08B580D /* Extensions */ = {
isa = PBXGroup;
children = (
- 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */,
- 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */,
- D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */,
- 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */,
- 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */,
- CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */,
- 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */,
+ 4D4ED76F5996A4308D2BC7C8 /* ItemListView+Drag.swift */,
+ 93CC030045617DBC90812D79 /* ItemListView+NavigationHeader.swift */,
+ E85BFCBD4DCB35CC1C8F9401 /* ItemListView+PullGestures.swift */,
+ 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */,
+ 78232E9D9F77FA3630F9D089 /* ItemListView+PullToCreate.swift */,
+ 869254E16B52F36616416DB4 /* ItemListView+Toolbar.swift */,
+ DB4B98BD127A56CE4669DCD5 /* ItemListView+Undo.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -382,9 +382,9 @@
isa = PBXGroup;
children = (
74255E6B6C40899E9B17D927 /* .gitkeep */,
+ 138DCA35ED82A745E4745175 /* ItemEntity.swift */,
+ 2B8EC97702E7B218A03B7898 /* ItemStore.swift */,
C093494053E6C348F245D4EC /* Listless.xcdatamodeld */,
- FBB8A3BEB346267B30B4675F /* TaskItem.swift */,
- DC3DEE364304587D280C5672 /* TaskStore.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -405,8 +405,8 @@
A1A9B54C4CBA03BEE12B34A9 /* Extensions */ = {
isa = PBXGroup;
children = (
- 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */,
- 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */,
+ 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */,
+ E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -414,9 +414,9 @@
D12ECC901ABED96B86CC85B5 /* Views */ = {
isa = PBXGroup;
children = (
+ 37F80F7BB632A04A687890F0 /* ItemListView.swift */,
+ 3048ACA1CAF1284F99E1400E /* ItemRowView.swift */,
CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */,
- 632DA39B24C4CF1528A1A24D /* TaskListView.swift */,
- E06485DBE35B60868E14202A /* TaskRowView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -476,8 +476,8 @@
F0EB58775713E4C83A8D477A /* Views */ = {
isa = PBXGroup;
children = (
- 56DEECB5DC31DE5F34FB55A3 /* TaskListView.swift */,
- 92D1AF0DE30657AAD86482CA /* TaskRowView.swift */,
+ 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */,
+ 4C7CB071709951F52C7742A3 /* ItemRowView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -487,10 +487,10 @@
children = (
C9B14DC786A336008AAB78EE /* .gitkeep */,
51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */,
- 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */,
- 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */,
- 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */,
- 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */,
+ 114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */,
+ 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */,
+ 6D1DF1C029FA3C6B12A58E7F /* ItemStoreOrderingTests.swift */,
+ 47892E3231D64596A2A00105 /* ItemStoreTests.swift */,
);
name = Unit;
path = Tests/Unit;
@@ -499,7 +499,7 @@
F7279CE4B2501F9F5111A3D8 /* Extensions */ = {
isa = PBXGroup;
children = (
- 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */,
+ 03343E3D48A0EF2B146528E2 /* ItemListView+Toolbar.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -735,10 +735,10 @@
buildActionMask = 2147483647;
files = (
A10BF9D0C850105E4FA1A2AD /* CloudKitErrorClassifierTests.swift in Sources */,
- 8FA8398315B67F12EA25FA35 /* TaskStoreCompletionTests.swift in Sources */,
- 54FCE72978F20AA64855B521 /* TaskStoreEdgeCaseTests.swift in Sources */,
- CDE538A0B3CA14B1CB421ED8 /* TaskStoreOrderingTests.swift in Sources */,
- E14F8177232BBC75FEEE1E2C /* TaskStoreTests.swift in Sources */,
+ 066E095075895DE73F217816 /* ItemStoreCompletionTests.swift in Sources */,
+ 84B31DA3CF21D57742C9217A /* ItemStoreEdgeCaseTests.swift in Sources */,
+ F2845953BA77F171FDA2A59F /* ItemStoreOrderingTests.swift in Sources */,
+ 12384AB44B4578E19EF8B0B7 /* ItemStoreTests.swift in Sources */,
E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -763,6 +763,16 @@
E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */,
E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */,
882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */,
+ BB16A28C4DA1695B722B45B2 /* ItemEntity.swift in Sources */,
+ 2921A4A343D43664F954B588 /* ItemListTypes.swift in Sources */,
+ 851F46417FE40D6BC765BC70 /* ItemListView+Logic.swift in Sources */,
+ A92F8503815056D3183CD7AE /* ItemListView+SyncUI.swift in Sources */,
+ 9E75E4AEEF577E0096E22DBA /* ItemListView+Toolbar.swift in Sources */,
+ 2323CE015C01C6354C557F90 /* ItemListView.swift in Sources */,
+ A34F23F6314067EFB35FB209 /* ItemListViewProtocol.swift in Sources */,
+ BDA8D53342F745B27B72B242 /* ItemRowDragGesture.swift in Sources */,
+ 194CAF0FBC308BE96CE8AA7B /* ItemRowView.swift in Sources */,
+ D24133A6C0105FE8E4528EF2 /* ItemStore.swift in Sources */,
2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */,
172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */,
96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
@@ -771,16 +781,6 @@
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */,
DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */,
568635BB34CD7EBE24E66A15 /* SyncDiagnosticsView.swift in Sources */,
- C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
- 93275BD83342D6CE94272E6A /* TaskListTypes.swift in Sources */,
- E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */,
- 642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */,
- 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */,
- FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */,
- 365FDEE6823D7A114F3FB12A /* TaskListViewProtocol.swift in Sources */,
- 5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */,
- 3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */,
- 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -791,14 +791,14 @@
543C8A0C8A9E2F77B2C0060F /* AccentColor.swift in Sources */,
F046B5A7F4C0BD7AAF46A69C /* CloudKitErrorClassifier.swift in Sources */,
53700EA974FE4AD771FE89EC /* CloudKitSyncMonitor.swift in Sources */,
+ 12FC79D42A110E2CDC753CCE /* ItemEntity.swift in Sources */,
+ 2F5309630692E89276CC3149 /* ItemListView.swift in Sources */,
+ 8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */,
+ 47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */,
264BD64C1DD30376E8BDAF79 /* KeyValueSyncBridge.swift in Sources */,
6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */,
65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */,
A119D0130DB77E30FBCB5436 /* PersistenceController.swift in Sources */,
- 684A80B692237B74EDF3C7A8 /* TaskItem.swift in Sources */,
- 07E2BB2FF9E75A922C3756AB /* TaskListView.swift in Sources */,
- 03BC4562430EC105221AB43B /* TaskRowView.swift in Sources */,
- 57E864F71C9C84B63A28E14F /* TaskStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -815,6 +815,25 @@
0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */,
E2FC6CF95A2C59CA147172EE /* FPSOverlay.swift in Sources */,
E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */,
+ 4263E6472020564AA702D117 /* ItemCardModifier.swift in Sources */,
+ 930AE396D982D7C46E498311 /* ItemEntity.swift in Sources */,
+ DBFE5DADCAAF26CF77245410 /* ItemListTypes.swift in Sources */,
+ 90BC899E66B98517A91F2627 /* ItemListView+Drag.swift in Sources */,
+ F56B03B24F33B31394D91512 /* ItemListView+Logic.swift in Sources */,
+ 89C4109374BD64464B0018B7 /* ItemListView+NavigationHeader.swift in Sources */,
+ 20D2A3E4694AE35AE6CF4AAD /* ItemListView+PullGestures.swift in Sources */,
+ 3383645AE13E9C3AAECFBD0B /* ItemListView+PullToClear.swift in Sources */,
+ 5716CA28163DE0EAAA875AEE /* ItemListView+PullToCreate.swift in Sources */,
+ 63C08E89303BD17601271D2C /* ItemListView+SyncUI.swift in Sources */,
+ E627F653B5A692B42A75FFBA /* ItemListView+Toolbar.swift in Sources */,
+ E0BDC0FCAB43CEE0C9AC5279 /* ItemListView+Undo.swift in Sources */,
+ 9082E96001188E516B7F903B /* ItemListView.swift in Sources */,
+ C7C69D883B45F1B4CE979AF7 /* ItemListViewProtocol.swift in Sources */,
+ DC103B1BFDC5940F63DD48ED /* ItemRowDragGesture.swift in Sources */,
+ D9325B2D23FF2CD644A1A7E3 /* ItemRowMetrics.swift in Sources */,
+ B0F8B09E2AB38C8C4AF74C10 /* ItemRowSwipeGesture.swift in Sources */,
+ DB0CBB6C3EE56406AF86FDE3 /* ItemRowView.swift in Sources */,
+ 790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */,
12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */,
19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */,
F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,
@@ -828,25 +847,6 @@
072594B24D88AD6D1DF7AFE5 /* SettingsView.swift in Sources */,
4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */,
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
- 354687FCBBCC2432340BD5EB /* TaskCardModifier.swift in Sources */,
- 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
- 1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */,
- 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,
- CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */,
- 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */,
- 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */,
- 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */,
- 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */,
- 4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */,
- 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */,
- 879608140FA8D7A32078C3CF /* TaskListView+Undo.swift in Sources */,
- C169823665158AA347A63990 /* TaskListView.swift in Sources */,
- CC99A96BBC089C423F582E4F /* TaskListViewProtocol.swift in Sources */,
- 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */,
- FF58A3AB2BCFFA42BF2F6413 /* TaskRowMetrics.swift in Sources */,
- 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */,
- 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */,
- 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */,
1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -864,10 +864,10 @@
buildActionMask = 2147483647;
files = (
CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */,
- F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */,
- 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */,
- 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */,
- ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */,
+ C89D4C17F91AF91F18B6EF4E /* ItemStoreCompletionTests.swift in Sources */,
+ 620D9398218A88B7E4C2331C /* ItemStoreEdgeCaseTests.swift in Sources */,
+ DAEA21531CFEA94D335FFC6E /* ItemStoreOrderingTests.swift in Sources */,
+ 1E31935122C5E97907B30C70 /* ItemStoreTests.swift in Sources */,
1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Listless/Extensions/ItemListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift
@@ -0,0 +1,657 @@
+import SwiftUI
+
+extension ItemListViewProtocol {
+
+ // MARK: - Computed Properties
+
+ var activeItems: [ItemEntity] {
+ Array(items.filter { !$0.isDeleted && !$0.isCompleted })
+ .sorted { $0.sortOrder < $1.sortOrder }
+ }
+
+ var displayActiveItems: [ItemEntity] {
+ guard let visualOrder else {
+ return activeItems
+ }
+
+ return visualOrder.compactMap { id in
+ activeItems.first(where: { $0.id == id })
+ }
+ }
+
+ var completedItems: [ItemEntity] {
+ Array(items.filter { !$0.isDeleted && $0.isCompleted })
+ .sorted { $0.completedOrder > $1.completedOrder }
+ }
+
+ var allItemsInDisplayOrder: [ItemEntity] {
+ displayActiveItems + completedItems
+ }
+
+ var editingItemID: UUID? {
+ if case .item(let id) = focusedField {
+ return id
+ }
+ return nil
+ }
+
+ var draggedItemID: UUID? {
+ if case .dragging(let id, _) = dragState {
+ return id
+ }
+ return nil
+ }
+
+ var visualOrder: [UUID]? {
+ if case .dragging(_, let order) = dragState {
+ return order
+ }
+ return nil
+ }
+
+ func presentStoreError(_ error: Error) {
+ syncMonitor.ingest(error: error)
+ }
+
+ private func isLastActiveItem(_ itemID: UUID) -> Bool {
+ guard let lastItem = activeItems.last else { return false }
+ return lastItem.id == itemID
+ }
+
+ func draftID(for placement: DraftItemPlacement) -> UUID {
+ switch placement {
+ case .prepend:
+ draftPrependRowID
+ case .append:
+ draftAppendRowID
+ }
+ }
+
+ func draftPlacement(for itemID: UUID) -> DraftItemPlacement? {
+ switch itemID {
+ case draftPrependRowID:
+ .prepend
+ case draftAppendRowID:
+ .append
+ default:
+ nil
+ }
+ }
+
+ // MARK: - Item Creation
+
+ func createNewItemAtTop() -> UUID {
+ revealDraftItem(at: .prepend)
+ return draftPrependRowID
+ }
+
+ func createNewItem() {
+ revealDraftItem(at: .append)
+ }
+
+ func revealDraftItem(at placement: DraftItemPlacement) {
+ if draftPlacement != placement, draftPlacement != nil {
+ commitDraftItem()
+ }
+
+ clearDragState()
+ let itemID = draftID(for: placement)
+ draftTitle = ""
+ draftPlacement = placement
+ fState.pendingFocus = .item(itemID)
+ focusedField = .item(itemID)
+ fState.selectedItemID = itemID
+ }
+
+ func beginDraftItemEditing(_ placement: DraftItemPlacement) {
+ guard draftPlacement == placement else { return }
+ let itemID = draftID(for: placement)
+ fState.selectedItemID = itemID
+ if case .item(let id) = fState.pendingFocus, id == itemID {
+ fState.pendingFocus = nil
+ }
+ }
+
+ func commitDraftItem(shouldCreateNewItem: Bool = false) {
+ guard let placement = draftPlacement else { return }
+ let itemID = draftID(for: placement)
+ let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Clear fState.pendingFocus before clearDraftItemUI so that the iOS
+ // onChange(of: focusedFieldBinding) nil-redirect doesn't re-focus
+ // the draft row via a stale fState.pendingFocus value.
+ if case .item(let id) = fState.pendingFocus, id == itemID {
+ fState.pendingFocus = nil
+ }
+
+ clearDraftItemUI(at: placement, hasTitle: !title.isEmpty)
+
+ if fState.selectedItemID == itemID {
+ fState.selectedItemID = nil
+ }
+
+ guard !title.isEmpty else { return }
+
+ do {
+ let item = switch placement {
+ case .prepend:
+ try store.createItem(title: title, atBeginning: true)
+ case .append:
+ try store.createItem(title: title)
+ }
+ try store.save()
+ if placement == .append {
+ fState.selectedItemID = item.id
+ }
+ } catch {
+ presentStoreError(error)
+ }
+
+ if shouldCreateNewItem, placement == .append {
+ revealDraftItem(at: .append)
+ }
+ }
+
+ func createItem(title: String, afterItemID: UUID) {
+ clearDragState()
+ do {
+ let sortOrder = try sortOrderAfter(itemID: afterItemID)
+ let newItem = try store.createItem(title: title, sortOrder: sortOrder)
+ try store.save()
+ fState.selectedItemID = newItem.id
+ focusedField = .scrollView
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ private func sortOrderAfter(itemID: UUID) throws -> Int64? {
+ guard let afterIndex = activeItems.firstIndex(where: { $0.id == itemID }) else {
+ return nil
+ }
+ let afterItem = activeItems[afterIndex]
+ if afterIndex + 1 < activeItems.count {
+ let nextItem = activeItems[afterIndex + 1]
+ let midpoint = (afterItem.sortOrder + nextItem.sortOrder) / 2
+ if midpoint == afterItem.sortOrder {
+ // Consecutive sort orders leave no room; re-normalise with 1000-unit gaps
+ // then recompute. Core Data's identity map ensures afterItem/nextItem reflect
+ // the updated values immediately after normalisation.
+ try store.normalizeSortOrders()
+ return (afterItem.sortOrder + nextItem.sortOrder) / 2
+ }
+ return midpoint
+ } else {
+ return afterItem.sortOrder + 1000
+ }
+ }
+
+ // MARK: - Interaction Handlers
+
+ func handleBackgroundTap() {
+ let isItemFocused = if case .item = focusedField { true } else { false }
+
+ if isItemFocused || fState.selectedItemID != nil {
+ fState.pendingFocus = nil
+ if draftPlacement != nil {
+ commitDraftItem()
+ }
+ fState.selectedItemID = nil
+ focusedField = nil
+ } else {
+ revealDraftItem(at: .append)
+ }
+ }
+
+ func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
+ let oldID = itemID(from: oldValue)
+ let newID = itemID(from: newValue)
+
+ guard oldID != newID, let oldID else {
+ return
+ }
+
+ if draftPlacement(for: oldID) != nil {
+ return
+ }
+
+ deleteIfEmpty(itemID: oldID)
+ }
+
+ private func itemID(from field: FocusField?) -> UUID? {
+ guard case .item(let id) = field else { return nil }
+ return id
+ }
+
+ private func deleteIfEmpty(itemID: UUID) {
+ if case .item(let pendingItemID) = fState.pendingFocus, pendingItemID == itemID {
+ return
+ }
+
+ guard let item = items.first(where: { $0.id == itemID }) else {
+ return
+ }
+ let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmedTitle.isEmpty else { return }
+
+ managedObjectContext.undoManager?.removeAllActions(withTarget: item)
+ managedObjectContext.undoManager?.disableUndoRegistration()
+ deleteItem(item)
+ managedObjectContext.undoManager?.enableUndoRegistration()
+ }
+
+ func updateTitle(_ item: ItemEntity, _ title: String) {
+ guard item.title != title else { return }
+ do {
+ try store.updateWithoutSaving(itemID: item.id, title: title)
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ func toggleCompletion(_ item: ItemEntity) {
+ do {
+ if item.isCompleted {
+ try store.uncomplete(itemID: item.id)
+ } else {
+ try store.complete(itemID: item.id)
+ }
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ func handleSwipeComplete(_ itemID: UUID) {
+ guard let item = items.first(where: { $0.id == itemID }) else { return }
+ toggleCompletion(item)
+ }
+
+ func handleSwipeDelete(_ itemID: UUID) {
+ guard let item = items.first(where: { $0.id == itemID }) else { return }
+ deleteItem(item)
+ }
+
+ func selectItem(
+ _ itemID: UUID,
+ extendSelection: Bool = false,
+ toggleSelection: Bool = false
+ ) {
+ if toggleSelection {
+ fState.toggleSelection(
+ itemID: itemID,
+ displayOrder: allItemsInDisplayOrder.map(\.id)
+ )
+ } else if extendSelection && fState.selectedItemID != nil {
+ if fState.anchorItemID == nil {
+ fState.anchorItemID = fState.cursorItemID
+ }
+ fState.extendSelection(
+ to: itemID,
+ displayOrder: allItemsInDisplayOrder.map(\.id)
+ )
+ } else {
+ fState.selectedItemID = itemID
+ }
+ }
+
+ func deleteItem(_ item: ItemEntity) {
+ guard !item.isDeleted else { return }
+ let itemID = item.id
+ do {
+ try store.delete(itemID: itemID)
+ if fState.selectedItemID == itemID {
+ fState.selectedItemID = nil
+ }
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ func clearCompletedItems() {
+ for item in completedItems.reversed() {
+ do {
+ try store.delete(itemID: item.id)
+ } catch {
+ presentStoreError(error)
+ }
+ }
+ }
+
+ // MARK: - Keyboard Navigation
+
+ func navigateUp() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+
+ guard let currentID = fState.selectedItemID else {
+ fState.selectedItemID = activeItems.last?.id
+ return .handled
+ }
+
+ let displayOrder = allItemsInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ return .handled
+ }
+
+ if currentIndex > 0 {
+ fState.selectedItemID = displayOrder[currentIndex - 1].id
+ }
+ return .handled
+ }
+
+ func navigateDown() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+
+ guard let currentID = fState.selectedItemID else {
+ fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
+ return .handled
+ }
+
+ let displayOrder = allItemsInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ return .handled
+ }
+
+ if currentIndex < displayOrder.count - 1 {
+ fState.selectedItemID = displayOrder[currentIndex + 1].id
+ }
+ return .handled
+ }
+
+ func navigateUpExtend() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+
+ let displayOrder = allItemsInDisplayOrder.map(\.id)
+
+ // If nothing is selected yet, start single-select at the bottom.
+ guard let cursorID = fState.cursorItemID else {
+ fState.selectedItemID = activeItems.last?.id
+ return .handled
+ }
+
+ guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
+ cursorIndex > 0
+ else {
+ return .handled
+ }
+
+ let targetID = displayOrder[cursorIndex - 1]
+ // On the first extend, the anchor is wherever the cursor is.
+ if !fState.hasMultipleSelection {
+ fState.anchorItemID = cursorID
+ }
+ fState.extendSelection(to: targetID, displayOrder: displayOrder)
+ return .handled
+ }
+
+ func navigateDownExtend() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+
+ let displayOrder = allItemsInDisplayOrder.map(\.id)
+
+ guard let cursorID = fState.cursorItemID else {
+ fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
+ return .handled
+ }
+
+ guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
+ cursorIndex < displayOrder.count - 1
+ else {
+ return .handled
+ }
+
+ let targetID = displayOrder[cursorIndex + 1]
+ if !fState.hasMultipleSelection {
+ fState.anchorItemID = cursorID
+ }
+ fState.extendSelection(to: targetID, displayOrder: displayOrder)
+ return .handled
+ }
+
+ func toggleSelectedItem() -> KeyPress.Result {
+ guard focusedField == .scrollView else { return .ignored }
+ let ids = fState.selectedItemIDs
+ guard !ids.isEmpty else { return .handled }
+ let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
+ guard !itemsToToggle.isEmpty else { return .handled }
+ let hasActive = itemsToToggle.contains { !$0.isCompleted }
+ let hasCompleted = itemsToToggle.contains { $0.isCompleted }
+ guard !(hasActive && hasCompleted) else { return .handled }
+ for item in itemsToToggle {
+ toggleCompletion(item)
+ }
+ return .handled
+ }
+
+ func focusSelectedItem() -> KeyPress.Result {
+ guard focusedField == .scrollView else { return .ignored }
+ guard !fState.hasMultipleSelection else { return .handled }
+ guard let currentID = fState.selectedItemID else { return .handled }
+ guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
+ return .handled
+ }
+ guard !item.isCompleted else { return .handled }
+ startEditing(currentID)
+ return .handled
+ }
+
+ func deleteSelectedItem() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+ let ids = fState.selectedItemIDs
+ guard !ids.isEmpty else { return .handled }
+ let displayOrder = allItemsInDisplayOrder
+ let itemsToDelete = displayOrder.filter { ids.contains($0.id) }
+ guard !itemsToDelete.isEmpty else { return .handled }
+
+ // Find the next item after the last selected one to move selection to.
+ let lastSelectedIndex = displayOrder.lastIndex(where: { ids.contains($0.id) })
+ let nextItem = lastSelectedIndex.flatMap { idx in
+ displayOrder.dropFirst(idx + 1).first(where: { !ids.contains($0.id) })
+ }
+
+ fState.selectedItemID = nil
+ for item in itemsToDelete {
+ deleteItem(item)
+ }
+ if let nextItem {
+ fState.selectedItemID = nextItem.id
+ }
+ return .handled
+ }
+
+ func moveSelectedItemUp() {
+ guard focusedField == .scrollView else { return }
+ guard let currentID = fState.selectedItemID else { return }
+ guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return }
+ guard currentIndex > 0 else { return }
+
+ do {
+ try store.moveItem(itemID: currentID, toIndex: currentIndex - 1)
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ func moveSelectedItemDown() {
+ guard focusedField == .scrollView else { return }
+ guard let currentID = fState.selectedItemID else { return }
+ guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return }
+ guard currentIndex < activeItems.count - 1 else { return }
+
+ do {
+ try store.moveItem(itemID: currentID, toIndex: currentIndex + 1)
+ } catch {
+ presentStoreError(error)
+ }
+ }
+
+ func markSelectedItemCompleted() {
+ guard focusedField == .scrollView else { return }
+ let ids = fState.selectedItemIDs
+ guard !ids.isEmpty else { return }
+ let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
+ for item in itemsToToggle {
+ toggleCompletion(item)
+ }
+ }
+
+ // MARK: - Focus Management
+
+ func focusTextField(_ itemID: UUID) {
+ focusedField = .item(itemID)
+ }
+
+ func startEditing(_ itemID: UUID) {
+ fState.selectedItemID = itemID
+ focusedField = .item(itemID)
+ fState.pendingFocus = nil
+ }
+
+ func endEditing(_ itemID: UUID, shouldCreateNewItem: Bool) {
+ if draftPlacement(for: itemID) != nil {
+ commitDraftItem(shouldCreateNewItem: shouldCreateNewItem)
+ return
+ }
+
+ do {
+ try store.save()
+ } catch {
+ presentStoreError(error)
+ }
+
+ let wasLastActiveItem = isLastActiveItem(itemID)
+ let willBeDeleted = shouldDeleteIfEmpty(itemID: itemID)
+
+ if willBeDeleted {
+ fState.selectedItemID = nil
+ deleteIfEmpty(itemID: itemID)
+ } else if wasLastActiveItem && shouldCreateNewItem {
+ revealDraftItem(at: .append)
+ } else if shouldCreateNewItem {
+ focusedField = .scrollView
+ }
+ }
+
+ private func shouldDeleteIfEmpty(itemID: UUID) -> Bool {
+ guard let item = items.first(where: { $0.id == itemID }) else {
+ return false
+ }
+ let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedTitle.isEmpty
+ }
+
+ // MARK: - Drag and Drop
+
+ func startDrag(itemID: UUID) {
+ guard case .idle = dragState else { return }
+ withAnimation(.easeOut(duration: 0.15)) {
+ dragState = .dragging(id: itemID, order: activeItems.map(\.id))
+ }
+ didStartDrag()
+ }
+
+ func updateVisualOrder(insertBefore targetID: UUID) {
+ guard let draggedID = draggedItemID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex)
+ }
+
+ if newOrder != order {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ setDragOrder(newOrder)
+ }
+ }
+ }
+
+ func updateVisualOrder(insertAfter targetID: UUID) {
+ guard let draggedID = draggedItemID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex + 1)
+ }
+
+ if newOrder != order {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ setDragOrder(newOrder)
+ }
+ }
+ }
+
+ func updateVisualOrderSmart(relativeTo targetID: UUID) {
+ guard let draggedID = draggedItemID,
+ let order = visualOrder
+ else { return }
+
+ guard let draggedIndex = order.firstIndex(of: draggedID),
+ let targetIndex = order.firstIndex(of: targetID)
+ else { return }
+
+ if draggedIndex < targetIndex {
+ updateVisualOrder(insertAfter: targetID)
+ } else {
+ updateVisualOrder(insertBefore: targetID)
+ }
+ }
+
+ func updateVisualOrder(insertAtEnd: Bool) {
+ guard let draggedID = draggedItemID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ newOrder.append(draggedID)
+
+ if newOrder != order {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ setDragOrder(newOrder)
+ }
+ }
+ }
+
+ func commitCurrentDrag() -> Bool {
+ guard let droppedUUID = draggedItemID,
+ let order = visualOrder,
+ let finalIndex = order.firstIndex(of: droppedUUID)
+ else {
+ clearDragState()
+ return false
+ }
+
+ do {
+ try store.moveItem(itemID: droppedUUID, toIndex: finalIndex)
+ clearDragState()
+ } catch {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ clearDragState()
+ }
+ presentStoreError(error)
+ }
+
+ return true
+ }
+
+ func setDragOrder(_ order: [UUID]) {
+ guard case .dragging(let id, _) = dragState else { return }
+ dragState = .dragging(id: id, order: order)
+ }
+
+ func clearDragState() {
+ dragState = .idle
+ }
+}
diff --git a/Listless/Extensions/ItemListView+SyncUI.swift b/Listless/Extensions/ItemListView+SyncUI.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+#if os(iOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+extension ItemListViewProtocol {
+ @ViewBuilder
+ var syncErrorBanner: some View {
+ if let message = syncMonitor.transientErrorMessage {
+ HStack(spacing: 8) {
+ Image(systemName: "icloud.slash")
+ .imageScale(.small)
+ Text(message)
+ .font(.caption)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.thickMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .padding(.horizontal, 12)
+ .padding(.top, 8)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+
+ func openSystemSettings() {
+ #if os(iOS)
+ guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(settingsURL)
+ #elseif os(macOS)
+ if let settingsURL = URL(string: "x-apple.systempreferences:") {
+ NSWorkspace.shared.open(settingsURL)
+ }
+ #endif
+ }
+}
diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift
@@ -1,657 +0,0 @@
-import SwiftUI
-
-extension TaskListViewProtocol {
-
- // MARK: - Computed Properties
-
- var activeTasks: [TaskItem] {
- Array(tasks.filter { !$0.isDeleted && !$0.isCompleted })
- .sorted { $0.sortOrder < $1.sortOrder }
- }
-
- var displayActiveTasks: [TaskItem] {
- guard let visualOrder else {
- return activeTasks
- }
-
- return visualOrder.compactMap { id in
- activeTasks.first(where: { $0.id == id })
- }
- }
-
- var completedTasks: [TaskItem] {
- Array(tasks.filter { !$0.isDeleted && $0.isCompleted })
- .sorted { $0.completedOrder > $1.completedOrder }
- }
-
- var allTasksInDisplayOrder: [TaskItem] {
- displayActiveTasks + completedTasks
- }
-
- var editingTaskID: UUID? {
- if case .task(let id) = focusedField {
- return id
- }
- return nil
- }
-
- var draggedTaskID: UUID? {
- if case .dragging(let id, _) = dragState {
- return id
- }
- return nil
- }
-
- var visualOrder: [UUID]? {
- if case .dragging(_, let order) = dragState {
- return order
- }
- return nil
- }
-
- func presentStoreError(_ error: Error) {
- syncMonitor.ingest(error: error)
- }
-
- private func isLastActiveTask(_ taskID: UUID) -> Bool {
- guard let lastTask = activeTasks.last else { return false }
- return lastTask.id == taskID
- }
-
- func draftID(for placement: DraftTaskPlacement) -> UUID {
- switch placement {
- case .prepend:
- draftPrependRowID
- case .append:
- draftAppendRowID
- }
- }
-
- func draftPlacement(for taskID: UUID) -> DraftTaskPlacement? {
- switch taskID {
- case draftPrependRowID:
- .prepend
- case draftAppendRowID:
- .append
- default:
- nil
- }
- }
-
- // MARK: - Task Creation
-
- func createNewTaskAtTop() -> UUID {
- revealDraftTask(at: .prepend)
- return draftPrependRowID
- }
-
- func createNewTask() {
- revealDraftTask(at: .append)
- }
-
- func revealDraftTask(at placement: DraftTaskPlacement) {
- if draftPlacement != placement, draftPlacement != nil {
- commitDraftTask()
- }
-
- clearDragState()
- let taskID = draftID(for: placement)
- draftTitle = ""
- draftPlacement = placement
- fState.pendingFocus = .task(taskID)
- focusedField = .task(taskID)
- fState.selectedTaskID = taskID
- }
-
- func beginDraftTaskEditing(_ placement: DraftTaskPlacement) {
- guard draftPlacement == placement else { return }
- let taskID = draftID(for: placement)
- fState.selectedTaskID = taskID
- if case .task(let id) = fState.pendingFocus, id == taskID {
- fState.pendingFocus = nil
- }
- }
-
- func commitDraftTask(shouldCreateNewTask: Bool = false) {
- guard let placement = draftPlacement else { return }
- let taskID = draftID(for: placement)
- let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
-
- // Clear fState.pendingFocus before clearDraftTaskUI so that the iOS
- // onChange(of: focusedFieldBinding) nil-redirect doesn't re-focus
- // the draft row via a stale fState.pendingFocus value.
- if case .task(let id) = fState.pendingFocus, id == taskID {
- fState.pendingFocus = nil
- }
-
- clearDraftTaskUI(at: placement, hasTitle: !title.isEmpty)
-
- if fState.selectedTaskID == taskID {
- fState.selectedTaskID = nil
- }
-
- guard !title.isEmpty else { return }
-
- do {
- let task = switch placement {
- case .prepend:
- try store.createTask(title: title, atBeginning: true)
- case .append:
- try store.createTask(title: title)
- }
- try store.save()
- if placement == .append {
- fState.selectedTaskID = task.id
- }
- } catch {
- presentStoreError(error)
- }
-
- if shouldCreateNewTask, placement == .append {
- revealDraftTask(at: .append)
- }
- }
-
- func createTask(title: String, afterTaskID: UUID) {
- clearDragState()
- do {
- let sortOrder = try sortOrderAfter(taskID: afterTaskID)
- let newTask = try store.createTask(title: title, sortOrder: sortOrder)
- try store.save()
- fState.selectedTaskID = newTask.id
- focusedField = .scrollView
- } catch {
- presentStoreError(error)
- }
- }
-
- private func sortOrderAfter(taskID: UUID) throws -> Int64? {
- guard let afterIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else {
- return nil
- }
- let afterTask = activeTasks[afterIndex]
- if afterIndex + 1 < activeTasks.count {
- let nextTask = activeTasks[afterIndex + 1]
- let midpoint = (afterTask.sortOrder + nextTask.sortOrder) / 2
- if midpoint == afterTask.sortOrder {
- // Consecutive sort orders leave no room; re-normalise with 1000-unit gaps
- // then recompute. Core Data's identity map ensures afterTask/nextTask reflect
- // the updated values immediately after normalisation.
- try store.normalizeSortOrders()
- return (afterTask.sortOrder + nextTask.sortOrder) / 2
- }
- return midpoint
- } else {
- return afterTask.sortOrder + 1000
- }
- }
-
- // MARK: - Interaction Handlers
-
- func handleBackgroundTap() {
- let isTaskFocused = if case .task = focusedField { true } else { false }
-
- if isTaskFocused || fState.selectedTaskID != nil {
- fState.pendingFocus = nil
- if draftPlacement != nil {
- commitDraftTask()
- }
- fState.selectedTaskID = nil
- focusedField = nil
- } else {
- revealDraftTask(at: .append)
- }
- }
-
- func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
- let oldID = taskID(from: oldValue)
- let newID = taskID(from: newValue)
-
- guard oldID != newID, let oldID else {
- return
- }
-
- if draftPlacement(for: oldID) != nil {
- return
- }
-
- deleteIfEmpty(taskID: oldID)
- }
-
- private func taskID(from field: FocusField?) -> UUID? {
- guard case .task(let id) = field else { return nil }
- return id
- }
-
- private func deleteIfEmpty(taskID: UUID) {
- if case .task(let pendingTaskID) = fState.pendingFocus, pendingTaskID == taskID {
- return
- }
-
- guard let task = tasks.first(where: { $0.id == taskID }) else {
- return
- }
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmedTitle.isEmpty else { return }
-
- managedObjectContext.undoManager?.removeAllActions(withTarget: task)
- managedObjectContext.undoManager?.disableUndoRegistration()
- deleteTask(task)
- managedObjectContext.undoManager?.enableUndoRegistration()
- }
-
- func updateTitle(_ task: TaskItem, _ title: String) {
- guard task.title != title else { return }
- do {
- try store.updateWithoutSaving(taskID: task.id, title: title)
- } catch {
- presentStoreError(error)
- }
- }
-
- func toggleCompletion(_ task: TaskItem) {
- do {
- if task.isCompleted {
- try store.uncomplete(taskID: task.id)
- } else {
- try store.complete(taskID: task.id)
- }
- } catch {
- presentStoreError(error)
- }
- }
-
- func handleSwipeComplete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- toggleCompletion(task)
- }
-
- func handleSwipeDelete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- deleteTask(task)
- }
-
- func selectTask(
- _ taskID: UUID,
- extendSelection: Bool = false,
- toggleSelection: Bool = false
- ) {
- if toggleSelection {
- fState.toggleSelection(
- taskID: taskID,
- displayOrder: allTasksInDisplayOrder.map(\.id)
- )
- } else if extendSelection && fState.selectedTaskID != nil {
- if fState.anchorTaskID == nil {
- fState.anchorTaskID = fState.cursorTaskID
- }
- fState.extendSelection(
- to: taskID,
- displayOrder: allTasksInDisplayOrder.map(\.id)
- )
- } else {
- fState.selectedTaskID = taskID
- }
- }
-
- func deleteTask(_ task: TaskItem) {
- guard !task.isDeleted else { return }
- let taskID = task.id
- do {
- try store.delete(taskID: taskID)
- if fState.selectedTaskID == taskID {
- fState.selectedTaskID = nil
- }
- } catch {
- presentStoreError(error)
- }
- }
-
- func clearCompletedTasks() {
- for task in completedTasks.reversed() {
- do {
- try store.delete(taskID: task.id)
- } catch {
- presentStoreError(error)
- }
- }
- }
-
- // MARK: - Keyboard Navigation
-
- func navigateUp() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
-
- guard let currentID = fState.selectedTaskID else {
- fState.selectedTaskID = activeTasks.last?.id
- return .handled
- }
-
- let displayOrder = allTasksInDisplayOrder
- guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
- return .handled
- }
-
- if currentIndex > 0 {
- fState.selectedTaskID = displayOrder[currentIndex - 1].id
- }
- return .handled
- }
-
- func navigateDown() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
-
- guard let currentID = fState.selectedTaskID else {
- fState.selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
- return .handled
- }
-
- let displayOrder = allTasksInDisplayOrder
- guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
- return .handled
- }
-
- if currentIndex < displayOrder.count - 1 {
- fState.selectedTaskID = displayOrder[currentIndex + 1].id
- }
- return .handled
- }
-
- func navigateUpExtend() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
-
- let displayOrder = allTasksInDisplayOrder.map(\.id)
-
- // If nothing is selected yet, start single-select at the bottom.
- guard let cursorID = fState.cursorTaskID else {
- fState.selectedTaskID = activeTasks.last?.id
- return .handled
- }
-
- guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
- cursorIndex > 0
- else {
- return .handled
- }
-
- let targetID = displayOrder[cursorIndex - 1]
- // On the first extend, the anchor is wherever the cursor is.
- if !fState.hasMultipleSelection {
- fState.anchorTaskID = cursorID
- }
- fState.extendSelection(to: targetID, displayOrder: displayOrder)
- return .handled
- }
-
- func navigateDownExtend() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
-
- let displayOrder = allTasksInDisplayOrder.map(\.id)
-
- guard let cursorID = fState.cursorTaskID else {
- fState.selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
- return .handled
- }
-
- guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
- cursorIndex < displayOrder.count - 1
- else {
- return .handled
- }
-
- let targetID = displayOrder[cursorIndex + 1]
- if !fState.hasMultipleSelection {
- fState.anchorTaskID = cursorID
- }
- fState.extendSelection(to: targetID, displayOrder: displayOrder)
- return .handled
- }
-
- func toggleSelectedTask() -> KeyPress.Result {
- guard focusedField == .scrollView else { return .ignored }
- let ids = fState.selectedTaskIDs
- guard !ids.isEmpty else { return .handled }
- let tasksToToggle = allTasksInDisplayOrder.filter { ids.contains($0.id) }
- guard !tasksToToggle.isEmpty else { return .handled }
- let hasActive = tasksToToggle.contains { !$0.isCompleted }
- let hasCompleted = tasksToToggle.contains { $0.isCompleted }
- guard !(hasActive && hasCompleted) else { return .handled }
- for task in tasksToToggle {
- toggleCompletion(task)
- }
- return .handled
- }
-
- func focusSelectedTask() -> KeyPress.Result {
- guard focusedField == .scrollView else { return .ignored }
- guard !fState.hasMultipleSelection else { return .handled }
- guard let currentID = fState.selectedTaskID else { return .handled }
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
- return .handled
- }
- guard !task.isCompleted else { return .handled }
- startEditing(currentID)
- return .handled
- }
-
- func deleteSelectedTask() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
- let ids = fState.selectedTaskIDs
- guard !ids.isEmpty else { return .handled }
- let displayOrder = allTasksInDisplayOrder
- let tasksToDelete = displayOrder.filter { ids.contains($0.id) }
- guard !tasksToDelete.isEmpty else { return .handled }
-
- // Find the next task after the last selected one to move selection to.
- let lastSelectedIndex = displayOrder.lastIndex(where: { ids.contains($0.id) })
- let nextTask = lastSelectedIndex.flatMap { idx in
- displayOrder.dropFirst(idx + 1).first(where: { !ids.contains($0.id) })
- }
-
- fState.selectedTaskID = nil
- for task in tasksToDelete {
- deleteTask(task)
- }
- if let nextTask {
- fState.selectedTaskID = nextTask.id
- }
- return .handled
- }
-
- func moveSelectedTaskUp() {
- guard focusedField == .scrollView else { return }
- guard let currentID = fState.selectedTaskID else { return }
- guard let currentIndex = activeTasks.firstIndex(where: { $0.id == currentID }) else { return }
- guard currentIndex > 0 else { return }
-
- do {
- try store.moveTask(taskID: currentID, toIndex: currentIndex - 1)
- } catch {
- presentStoreError(error)
- }
- }
-
- func moveSelectedTaskDown() {
- guard focusedField == .scrollView else { return }
- guard let currentID = fState.selectedTaskID else { return }
- guard let currentIndex = activeTasks.firstIndex(where: { $0.id == currentID }) else { return }
- guard currentIndex < activeTasks.count - 1 else { return }
-
- do {
- try store.moveTask(taskID: currentID, toIndex: currentIndex + 1)
- } catch {
- presentStoreError(error)
- }
- }
-
- func markSelectedTaskCompleted() {
- guard focusedField == .scrollView else { return }
- let ids = fState.selectedTaskIDs
- guard !ids.isEmpty else { return }
- let tasksToToggle = allTasksInDisplayOrder.filter { ids.contains($0.id) }
- for task in tasksToToggle {
- toggleCompletion(task)
- }
- }
-
- // MARK: - Focus Management
-
- func focusTextField(_ taskID: UUID) {
- focusedField = .task(taskID)
- }
-
- func startEditing(_ taskID: UUID) {
- fState.selectedTaskID = taskID
- focusedField = .task(taskID)
- fState.pendingFocus = nil
- }
-
- func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) {
- if draftPlacement(for: taskID) != nil {
- commitDraftTask(shouldCreateNewTask: shouldCreateNewTask)
- return
- }
-
- do {
- try store.save()
- } catch {
- presentStoreError(error)
- }
-
- let wasLastActiveTask = isLastActiveTask(taskID)
- let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
-
- if willBeDeleted {
- fState.selectedTaskID = nil
- deleteIfEmpty(taskID: taskID)
- } else if wasLastActiveTask && shouldCreateNewTask {
- revealDraftTask(at: .append)
- } else if shouldCreateNewTask {
- focusedField = .scrollView
- }
- }
-
- private func shouldDeleteIfEmpty(taskID: UUID) -> Bool {
- guard let task = tasks.first(where: { $0.id == taskID }) else {
- return false
- }
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmedTitle.isEmpty
- }
-
- // MARK: - Drag and Drop
-
- func startDrag(taskID: UUID) {
- guard case .idle = dragState else { return }
- withAnimation(.easeOut(duration: 0.15)) {
- dragState = .dragging(id: taskID, order: activeTasks.map(\.id))
- }
- didStartDrag()
- }
-
- func updateVisualOrder(insertBefore targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- if let targetIndex = newOrder.firstIndex(of: targetID) {
- newOrder.insert(draggedID, at: targetIndex)
- }
-
- if newOrder != order {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- setDragOrder(newOrder)
- }
- }
- }
-
- func updateVisualOrder(insertAfter targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- if let targetIndex = newOrder.firstIndex(of: targetID) {
- newOrder.insert(draggedID, at: targetIndex + 1)
- }
-
- if newOrder != order {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- setDragOrder(newOrder)
- }
- }
- }
-
- func updateVisualOrderSmart(relativeTo targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- guard let draggedIndex = order.firstIndex(of: draggedID),
- let targetIndex = order.firstIndex(of: targetID)
- else { return }
-
- if draggedIndex < targetIndex {
- updateVisualOrder(insertAfter: targetID)
- } else {
- updateVisualOrder(insertBefore: targetID)
- }
- }
-
- func updateVisualOrder(insertAtEnd: Bool) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- newOrder.append(draggedID)
-
- if newOrder != order {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- setDragOrder(newOrder)
- }
- }
- }
-
- func commitCurrentDrag() -> Bool {
- guard let droppedUUID = draggedTaskID,
- let order = visualOrder,
- let finalIndex = order.firstIndex(of: droppedUUID)
- else {
- clearDragState()
- return false
- }
-
- do {
- try store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
- clearDragState()
- } catch {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- clearDragState()
- }
- presentStoreError(error)
- }
-
- return true
- }
-
- func setDragOrder(_ order: [UUID]) {
- guard case .dragging(let id, _) = dragState else { return }
- dragState = .dragging(id: id, order: order)
- }
-
- func clearDragState() {
- dragState = .idle
- }
-}
diff --git a/Listless/Extensions/TaskListView+SyncUI.swift b/Listless/Extensions/TaskListView+SyncUI.swift
@@ -1,40 +0,0 @@
-import SwiftUI
-#if os(iOS)
-import UIKit
-#elseif os(macOS)
-import AppKit
-#endif
-
-extension TaskListViewProtocol {
- @ViewBuilder
- var syncErrorBanner: some View {
- if let message = syncMonitor.transientErrorMessage {
- HStack(spacing: 8) {
- Image(systemName: "icloud.slash")
- .imageScale(.small)
- Text(message)
- .font(.caption)
- .lineLimit(2)
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(.thickMaterial)
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
- .padding(.horizontal, 12)
- .padding(.top, 8)
- .transition(.move(edge: .top).combined(with: .opacity))
- }
- }
-
- func openSystemSettings() {
- #if os(iOS)
- guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
- UIApplication.shared.open(settingsURL)
- #elseif os(macOS)
- if let settingsURL = URL(string: "x-apple.systempreferences:") {
- NSWorkspace.shared.open(settingsURL)
- }
- #endif
- }
-}
diff --git a/Listless/Helpers/AccentColor.swift b/Listless/Helpers/AccentColor.swift
@@ -41,18 +41,18 @@ enum ColorTheme: Int, CaseIterable, Identifiable {
}
}
-private struct TaskAccentColorKey: Hashable {
+private struct ItemAccentColorKey: Hashable {
let index: Int
let total: Int
let theme: ColorTheme
}
@MainActor
-private enum TaskAccentColorCache {
- static var colors: [TaskAccentColorKey: Color] = [:]
+private enum ItemAccentColorCache {
+ static var colors: [ItemAccentColorKey: Color] = [:]
}
-func taskColor(forIndex index: Int, total: Int, theme: ColorTheme = .pilbara) -> Color {
+func itemColor(forIndex index: Int, total: Int, theme: ColorTheme = .pilbara) -> Color {
let top = theme.top
guard total > 1 else { return Color(hue: top.h, saturation: top.s, brightness: top.b) }
@@ -68,14 +68,14 @@ func taskColor(forIndex index: Int, total: Int, theme: ColorTheme = .pilbara) ->
}
@MainActor
-func cachedTaskColor(forIndex index: Int, total: Int, theme: ColorTheme = .pilbara) -> Color {
- let key = TaskAccentColorKey(index: index, total: total, theme: theme)
- if let cached = TaskAccentColorCache.colors[key] {
+func cachedItemColor(forIndex index: Int, total: Int, theme: ColorTheme = .pilbara) -> Color {
+ let key = ItemAccentColorKey(index: index, total: total, theme: theme)
+ if let cached = ItemAccentColorCache.colors[key] {
return cached
}
- let computed = taskColor(forIndex: index, total: total, theme: theme)
- TaskAccentColorCache.colors[key] = computed
+ let computed = itemColor(forIndex: index, total: total, theme: theme)
+ ItemAccentColorCache.colors[key] = computed
return computed
}
diff --git a/Listless/Helpers/ItemListTypes.swift b/Listless/Helpers/ItemListTypes.swift
@@ -0,0 +1,199 @@
+import Foundation
+
+enum FocusField: Hashable {
+ case item(UUID)
+ case scrollView
+}
+
+enum DragState: Equatable {
+ case idle
+ case dragging(id: UUID, order: [UUID])
+}
+
+enum DraftItemPlacement: Equatable {
+ case prepend
+ case append
+}
+
+struct FocusStateData {
+ var focusedField: FocusField?
+ var pendingFocus: FocusField?
+
+ // MARK: - Selection
+
+ /// The full set of selected item IDs (supports multi-select on macOS).
+ private(set) var selectedItemIDs: Set<UUID> = []
+
+ /// The start of a Shift+Arrow range selection. Stays fixed while the
+ /// cursor moves via repeated Shift+Arrow presses.
+ var anchorItemID: UUID?
+
+ /// The current cursor position. During single-select this equals the
+ /// anchor. During Shift+Arrow it tracks the moving end of the range.
+ private(set) var cursorItemID: UUID?
+
+ /// Selected items outside the active anchor–cursor range, preserved
+ /// across Shift+Arrow operations after a Cmd+Click toggle.
+ private(set) var inactiveSelections: Set<UUID> = []
+
+ /// Single-select convenience. Getting returns the cursor (i.e. the
+ /// position plain Arrow keys navigate from); setting resets to a
+ /// single-element (or empty) selection, keeping all existing call
+ /// sites working without modification.
+ var selectedItemID: UUID? {
+ get { cursorItemID }
+ set {
+ anchorItemID = newValue
+ cursorItemID = newValue
+ selectedItemIDs = newValue.map { Set([$0]) } ?? []
+ inactiveSelections = []
+ }
+ }
+
+ func isItemSelected(_ id: UUID) -> Bool {
+ selectedItemIDs.contains(id)
+ }
+
+ var hasMultipleSelection: Bool {
+ selectedItemIDs.count > 1
+ }
+
+ /// Select all items in display order, anchoring at the first and
+ /// placing the cursor at the last.
+ mutating func selectAll(displayOrder: [UUID]) {
+ guard !displayOrder.isEmpty else { return }
+ anchorItemID = displayOrder.first
+ cursorItemID = displayOrder.last
+ selectedItemIDs = Set(displayOrder)
+ inactiveSelections = []
+ }
+
+ /// Toggle a single item in/out of the selection (Cmd+Click).
+ /// Sets anchor to the item below the toggled item in display order.
+ /// When deselecting, cursor stays at its previous position so
+ /// Shift+Arrow contracts from the far end. When adding, cursor
+ /// resets to anchor so the active range stays small and other
+ /// selections are preserved as inactive.
+ mutating func toggleSelection(itemID: UUID, displayOrder: [UUID]) {
+ guard let toggledIndex = displayOrder.firstIndex(of: itemID) else { return }
+
+ let wasSelected = selectedItemIDs.contains(itemID)
+ if wasSelected {
+ selectedItemIDs.remove(itemID)
+ } else {
+ selectedItemIDs.insert(itemID)
+ }
+
+ guard !selectedItemIDs.isEmpty else {
+ anchorItemID = nil
+ cursorItemID = nil
+ inactiveSelections = []
+ return
+ }
+
+ // Anchor = item below the toggled item (or self if at bottom).
+ anchorItemID =
+ toggledIndex + 1 < displayOrder.count
+ ? displayOrder[toggledIndex + 1]
+ : displayOrder[toggledIndex]
+
+ if wasSelected {
+ // Deselecting: cursor stays so Shift+Arrow contracts from
+ // the far end of the remaining selection.
+ if cursorItemID == nil {
+ cursorItemID = anchorItemID
+ }
+ } else {
+ // Adding: cursor resets to anchor so the active range is
+ // small, preserving other selections as inactive.
+ cursorItemID = anchorItemID
+ }
+
+ recomputeInactiveSelections(displayOrder: displayOrder)
+ }
+
+ /// Extend or contract the selection from the anchor to `targetID`,
+ /// selecting all items between them in `displayOrder`. Inactive
+ /// selections are preserved and merged when they become adjacent
+ /// to the active range.
+ mutating func extendSelection(to targetID: UUID, displayOrder: [UUID]) {
+ guard let anchorID = anchorItemID,
+ let anchorIndex = displayOrder.firstIndex(of: anchorID),
+ let targetIndex = displayOrder.firstIndex(of: targetID)
+ else {
+ return
+ }
+ let lo = min(anchorIndex, targetIndex)
+ let hi = max(anchorIndex, targetIndex)
+ let activeRange = Set(displayOrder[lo...hi])
+ selectedItemIDs = inactiveSelections.union(activeRange)
+ cursorItemID = targetID
+ mergeAdjacentInactiveSelections(displayOrder: displayOrder)
+ }
+
+ // MARK: - Private Helpers
+
+ /// Partition `selectedItemIDs` into those inside vs outside the
+ /// anchor–cursor range.
+ private mutating func recomputeInactiveSelections(displayOrder: [UUID]) {
+ guard let anchorID = anchorItemID, let cursorID = cursorItemID,
+ let anchorIndex = displayOrder.firstIndex(of: anchorID),
+ let cursorIndex = displayOrder.firstIndex(of: cursorID)
+ else {
+ inactiveSelections = []
+ return
+ }
+ let lo = min(anchorIndex, cursorIndex)
+ let hi = max(anchorIndex, cursorIndex)
+ let activeRange = Set(displayOrder[lo...hi])
+ inactiveSelections = selectedItemIDs.subtracting(activeRange)
+ }
+
+ /// When the active range becomes adjacent to inactive selections,
+ /// absorb them: clear the merged items from `inactiveSelections`
+ /// and jump the cursor to the far end of the merged region (away
+ /// from the anchor).
+ private mutating func mergeAdjacentInactiveSelections(displayOrder: [UUID]) {
+ guard !inactiveSelections.isEmpty,
+ let anchorID = anchorItemID,
+ let anchorIndex = displayOrder.firstIndex(of: anchorID),
+ let cursorID = cursorItemID,
+ let cursorIndex = displayOrder.firstIndex(of: cursorID)
+ else {
+ return
+ }
+
+ var lo = min(anchorIndex, cursorIndex)
+ var hi = max(anchorIndex, cursorIndex)
+ var mergedIDs: Set<UUID> = []
+ var changed = true
+
+ while changed {
+ changed = false
+ for inactiveID in inactiveSelections where !mergedIDs.contains(inactiveID) {
+ guard let idx = displayOrder.firstIndex(of: inactiveID) else { continue }
+ if idx == lo - 1 || idx == hi + 1 || (idx >= lo && idx <= hi) {
+ if idx < lo { lo = idx }
+ if idx > hi { hi = idx }
+ mergedIDs.insert(inactiveID)
+ changed = true
+ }
+ }
+ }
+
+ guard !mergedIDs.isEmpty else { return }
+
+ inactiveSelections.subtract(mergedIDs)
+
+ if cursorIndex <= anchorIndex {
+ cursorItemID = displayOrder[lo]
+ } else {
+ cursorItemID = displayOrder[hi]
+ }
+
+ selectedItemIDs = inactiveSelections.union(Set(displayOrder[lo...hi]))
+ }
+}
+
+let draftPrependRowID = UUID()
+let draftAppendRowID = UUID()
diff --git a/Listless/Helpers/ItemListViewProtocol.swift b/Listless/Helpers/ItemListViewProtocol.swift
@@ -0,0 +1,17 @@
+import CoreData
+import SwiftUI
+
+@MainActor
+protocol ItemListViewProtocol {
+ var items: FetchedResults<ItemEntity> { get }
+ var store: ItemStore { get }
+ var syncMonitor: CloudKitSyncMonitor { get }
+ var managedObjectContext: NSManagedObjectContext { get }
+ var focusedField: FocusField? { get nonmutating set }
+ var fState: FocusStateData { get nonmutating set }
+ var dragState: DragState { get nonmutating set }
+ var draftPlacement: DraftItemPlacement? { get nonmutating set }
+ var draftTitle: String { get nonmutating set }
+ func didStartDrag()
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool)
+}
diff --git a/Listless/Helpers/TaskListTypes.swift b/Listless/Helpers/TaskListTypes.swift
@@ -1,199 +0,0 @@
-import Foundation
-
-enum FocusField: Hashable {
- case task(UUID)
- case scrollView
-}
-
-enum DragState: Equatable {
- case idle
- case dragging(id: UUID, order: [UUID])
-}
-
-enum DraftTaskPlacement: Equatable {
- case prepend
- case append
-}
-
-struct FocusStateData {
- var focusedField: FocusField?
- var pendingFocus: FocusField?
-
- // MARK: - Selection
-
- /// The full set of selected task IDs (supports multi-select on macOS).
- private(set) var selectedTaskIDs: Set<UUID> = []
-
- /// The start of a Shift+Arrow range selection. Stays fixed while the
- /// cursor moves via repeated Shift+Arrow presses.
- var anchorTaskID: UUID?
-
- /// The current cursor position. During single-select this equals the
- /// anchor. During Shift+Arrow it tracks the moving end of the range.
- private(set) var cursorTaskID: UUID?
-
- /// Selected items outside the active anchor–cursor range, preserved
- /// across Shift+Arrow operations after a Cmd+Click toggle.
- private(set) var inactiveSelections: Set<UUID> = []
-
- /// Single-select convenience. Getting returns the cursor (i.e. the
- /// position plain Arrow keys navigate from); setting resets to a
- /// single-element (or empty) selection, keeping all existing call
- /// sites working without modification.
- var selectedTaskID: UUID? {
- get { cursorTaskID }
- set {
- anchorTaskID = newValue
- cursorTaskID = newValue
- selectedTaskIDs = newValue.map { Set([$0]) } ?? []
- inactiveSelections = []
- }
- }
-
- func isTaskSelected(_ id: UUID) -> Bool {
- selectedTaskIDs.contains(id)
- }
-
- var hasMultipleSelection: Bool {
- selectedTaskIDs.count > 1
- }
-
- /// Select all tasks in display order, anchoring at the first and
- /// placing the cursor at the last.
- mutating func selectAll(displayOrder: [UUID]) {
- guard !displayOrder.isEmpty else { return }
- anchorTaskID = displayOrder.first
- cursorTaskID = displayOrder.last
- selectedTaskIDs = Set(displayOrder)
- inactiveSelections = []
- }
-
- /// Toggle a single item in/out of the selection (Cmd+Click).
- /// Sets anchor to the item below the toggled item in display order.
- /// When deselecting, cursor stays at its previous position so
- /// Shift+Arrow contracts from the far end. When adding, cursor
- /// resets to anchor so the active range stays small and other
- /// selections are preserved as inactive.
- mutating func toggleSelection(taskID: UUID, displayOrder: [UUID]) {
- guard let toggledIndex = displayOrder.firstIndex(of: taskID) else { return }
-
- let wasSelected = selectedTaskIDs.contains(taskID)
- if wasSelected {
- selectedTaskIDs.remove(taskID)
- } else {
- selectedTaskIDs.insert(taskID)
- }
-
- guard !selectedTaskIDs.isEmpty else {
- anchorTaskID = nil
- cursorTaskID = nil
- inactiveSelections = []
- return
- }
-
- // Anchor = item below the toggled item (or self if at bottom).
- anchorTaskID =
- toggledIndex + 1 < displayOrder.count
- ? displayOrder[toggledIndex + 1]
- : displayOrder[toggledIndex]
-
- if wasSelected {
- // Deselecting: cursor stays so Shift+Arrow contracts from
- // the far end of the remaining selection.
- if cursorTaskID == nil {
- cursorTaskID = anchorTaskID
- }
- } else {
- // Adding: cursor resets to anchor so the active range is
- // small, preserving other selections as inactive.
- cursorTaskID = anchorTaskID
- }
-
- recomputeInactiveSelections(displayOrder: displayOrder)
- }
-
- /// Extend or contract the selection from the anchor to `targetID`,
- /// selecting all tasks between them in `displayOrder`. Inactive
- /// selections are preserved and merged when they become adjacent
- /// to the active range.
- mutating func extendSelection(to targetID: UUID, displayOrder: [UUID]) {
- guard let anchorID = anchorTaskID,
- let anchorIndex = displayOrder.firstIndex(of: anchorID),
- let targetIndex = displayOrder.firstIndex(of: targetID)
- else {
- return
- }
- let lo = min(anchorIndex, targetIndex)
- let hi = max(anchorIndex, targetIndex)
- let activeRange = Set(displayOrder[lo...hi])
- selectedTaskIDs = inactiveSelections.union(activeRange)
- cursorTaskID = targetID
- mergeAdjacentInactiveSelections(displayOrder: displayOrder)
- }
-
- // MARK: - Private Helpers
-
- /// Partition `selectedTaskIDs` into those inside vs outside the
- /// anchor–cursor range.
- private mutating func recomputeInactiveSelections(displayOrder: [UUID]) {
- guard let anchorID = anchorTaskID, let cursorID = cursorTaskID,
- let anchorIndex = displayOrder.firstIndex(of: anchorID),
- let cursorIndex = displayOrder.firstIndex(of: cursorID)
- else {
- inactiveSelections = []
- return
- }
- let lo = min(anchorIndex, cursorIndex)
- let hi = max(anchorIndex, cursorIndex)
- let activeRange = Set(displayOrder[lo...hi])
- inactiveSelections = selectedTaskIDs.subtracting(activeRange)
- }
-
- /// When the active range becomes adjacent to inactive selections,
- /// absorb them: clear the merged items from `inactiveSelections`
- /// and jump the cursor to the far end of the merged region (away
- /// from the anchor).
- private mutating func mergeAdjacentInactiveSelections(displayOrder: [UUID]) {
- guard !inactiveSelections.isEmpty,
- let anchorID = anchorTaskID,
- let anchorIndex = displayOrder.firstIndex(of: anchorID),
- let cursorID = cursorTaskID,
- let cursorIndex = displayOrder.firstIndex(of: cursorID)
- else {
- return
- }
-
- var lo = min(anchorIndex, cursorIndex)
- var hi = max(anchorIndex, cursorIndex)
- var mergedIDs: Set<UUID> = []
- var changed = true
-
- while changed {
- changed = false
- for inactiveID in inactiveSelections where !mergedIDs.contains(inactiveID) {
- guard let idx = displayOrder.firstIndex(of: inactiveID) else { continue }
- if idx == lo - 1 || idx == hi + 1 || (idx >= lo && idx <= hi) {
- if idx < lo { lo = idx }
- if idx > hi { hi = idx }
- mergedIDs.insert(inactiveID)
- changed = true
- }
- }
- }
-
- guard !mergedIDs.isEmpty else { return }
-
- inactiveSelections.subtract(mergedIDs)
-
- if cursorIndex <= anchorIndex {
- cursorTaskID = displayOrder[lo]
- } else {
- cursorTaskID = displayOrder[hi]
- }
-
- selectedTaskIDs = inactiveSelections.union(Set(displayOrder[lo...hi]))
- }
-}
-
-let draftPrependRowID = UUID()
-let draftAppendRowID = UUID()
diff --git a/Listless/Helpers/TaskListViewProtocol.swift b/Listless/Helpers/TaskListViewProtocol.swift
@@ -1,17 +0,0 @@
-import CoreData
-import SwiftUI
-
-@MainActor
-protocol TaskListViewProtocol {
- var tasks: FetchedResults<TaskItem> { get }
- var store: TaskStore { get }
- var syncMonitor: CloudKitSyncMonitor { get }
- var managedObjectContext: NSManagedObjectContext { get }
- var focusedField: FocusField? { get nonmutating set }
- var fState: FocusStateData { get nonmutating set }
- var dragState: DragState { get nonmutating set }
- var draftPlacement: DraftTaskPlacement? { get nonmutating set }
- var draftTitle: String { get nonmutating set }
- func didStartDrag()
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool)
-}
diff --git a/Listless/Models/ItemEntity.swift b/Listless/Models/ItemEntity.swift
@@ -0,0 +1,48 @@
+import CoreData
+import Foundation
+
+@objc(TaskItem)
+public class ItemEntity: NSManagedObject, Identifiable {
+ private enum Keys {
+ private static func key<Value>(_ keyPath: KeyPath<ItemEntity, Value>) -> String {
+ NSExpression(forKeyPath: keyPath).keyPath
+ }
+
+ static let id = key(\ItemEntity.id as KeyPath<ItemEntity, UUID>)
+ static let title = key(\ItemEntity.title)
+ static let createdAt = key(\ItemEntity.createdAt)
+ static let updatedAt = key(\ItemEntity.updatedAt)
+ static let sortOrder = key(\ItemEntity.sortOrder)
+ static let completedOrder = key(\ItemEntity.completedOrder)
+ }
+
+ @NSManaged public var id: UUID
+ @NSManaged public var title: String
+ @NSManaged public var createdAt: Date
+ @NSManaged public var updatedAt: Date
+ @NSManaged public var sortOrder: Int64
+ @NSManaged public var completedOrder: Int64
+
+ public var isCompleted: Bool { completedOrder > 0 }
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest<ItemEntity> {
+ return NSFetchRequest<ItemEntity>(entityName: "TaskItem")
+ }
+
+ public override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(UUID(), forKey: Keys.id)
+ setPrimitiveValue(Date(), forKey: Keys.createdAt)
+ setPrimitiveValue(Date(), forKey: Keys.updatedAt)
+ setPrimitiveValue("", forKey: Keys.title)
+ setPrimitiveValue(0, forKey: Keys.sortOrder)
+ setPrimitiveValue(0, forKey: Keys.completedOrder)
+ }
+
+ public override func willSave() {
+ super.willSave()
+ if !isDeleted && changedValues().keys.contains(where: { $0 != Keys.updatedAt }) {
+ setPrimitiveValue(Date(), forKey: Keys.updatedAt)
+ }
+ }
+}
diff --git a/Listless/Models/ItemStore.swift b/Listless/Models/ItemStore.swift
@@ -0,0 +1,201 @@
+import CoreData
+import Foundation
+
+enum ItemStoreError: LocalizedError {
+ case fetchFailed(Error)
+ case saveFailed(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .fetchFailed(let error):
+ return "Failed to fetch items: \(error.localizedDescription)"
+ case .saveFailed(let error):
+ return "Failed to save changes: \(error.localizedDescription)"
+ }
+ }
+}
+
+@MainActor
+final class ItemStore {
+ private let persistenceController: PersistenceController
+ private var context: NSManagedObjectContext {
+ persistenceController.viewContext
+ }
+
+ init(persistenceController: PersistenceController = .shared) {
+ self.persistenceController = persistenceController
+ }
+
+ func fetchItems() throws -> [ItemEntity] {
+ do {
+ let activeRequest = ItemEntity.fetchRequest()
+ activeRequest.predicate = NSPredicate(format: "completedOrder == 0")
+ activeRequest.sortDescriptors = [
+ NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true)
+ ]
+
+ let completedRequest = ItemEntity.fetchRequest()
+ completedRequest.predicate = NSPredicate(format: "completedOrder > 0")
+ completedRequest.sortDescriptors = [
+ NSSortDescriptor(keyPath: \ItemEntity.completedOrder, ascending: false)
+ ]
+
+ let activeItems = try context.fetch(activeRequest)
+ let completedItems = try context.fetch(completedRequest)
+
+ return activeItems + completedItems
+ } catch {
+ throw ItemStoreError.fetchFailed(error)
+ }
+ }
+
+ func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> ItemEntity {
+ // Compute sort order before inserting the new object so we don't need
+ // processPendingChanges() and the new item can't appear in our own query.
+ let resolvedSortOrder: Int64
+ if let sortOrder {
+ resolvedSortOrder = sortOrder
+ } else if atBeginning {
+ let minOrder = try minActiveSortOrder() ?? 0
+ resolvedSortOrder = minOrder - 1000
+ } else {
+ let maxOrder = try maxActiveSortOrder() ?? -1000
+ resolvedSortOrder = maxOrder + 1000
+ }
+
+ let item = ItemEntity(context: context)
+ item.title = title
+ item.sortOrder = resolvedSortOrder
+
+ return item
+ }
+
+ private func minActiveSortOrder() throws -> Int64? {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "completedOrder == 0")
+ request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true)]
+ request.fetchLimit = 1
+ do {
+ return try context.fetch(request).first?.sortOrder
+ } catch {
+ throw ItemStoreError.fetchFailed(error)
+ }
+ }
+
+ private func maxActiveSortOrder() throws -> Int64? {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "completedOrder == 0")
+ request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false)]
+ request.fetchLimit = 1
+ do {
+ return try context.fetch(request).first?.sortOrder
+ } catch {
+ throw ItemStoreError.fetchFailed(error)
+ }
+ }
+
+ func complete(itemID: UUID) throws {
+ guard let item = try findItem(id: itemID) else { return }
+
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "completedOrder > 0")
+ request.sortDescriptors = [
+ NSSortDescriptor(keyPath: \ItemEntity.completedOrder, ascending: false)
+ ]
+ request.fetchLimit = 1
+ let maxOrder = (try context.fetch(request).first)?.completedOrder ?? 0
+
+ item.completedOrder = maxOrder + 1
+ try save()
+ }
+
+ func uncomplete(itemID: UUID) throws {
+ guard let item = try findItem(id: itemID) else { return }
+ let restoredSortOrder = item.sortOrder
+ let activeItems = try fetchItems().filter { !$0.isCompleted && $0.id != item.id }
+ let hasSortOrderConflict = activeItems.contains { $0.sortOrder == restoredSortOrder }
+
+ if hasSortOrderConflict {
+ let maxSortOrder = activeItems.map(\.sortOrder).max() ?? -1000
+ item.sortOrder = maxSortOrder + 1000
+ }
+
+ item.completedOrder = 0
+ try save()
+ }
+
+ func update(itemID: UUID, title: String) throws {
+ guard let item = try findItem(id: itemID) else { return }
+ item.title = title
+ try save()
+ }
+
+ func updateWithoutSaving(itemID: UUID, title: String) throws {
+ guard let item = try findItem(id: itemID) else { return }
+ item.title = title
+ // Don't save - will be saved when editing ends
+ }
+
+ func delete(itemID: UUID) throws {
+ guard let item = try findItem(id: itemID) else { return }
+ context.delete(item)
+ try save()
+ }
+
+ func deleteMultiple(itemIDs: [UUID]) throws {
+ for itemID in itemIDs {
+ guard let item = try findItem(id: itemID) else { continue }
+ context.delete(item)
+ }
+ try save()
+ }
+
+ func normalizeSortOrders() throws {
+ let activeItems = try fetchItems().filter { !$0.isCompleted }
+ .sorted { $0.sortOrder < $1.sortOrder }
+
+ for (index, item) in activeItems.enumerated() {
+ item.sortOrder = Int64(index) * 1000
+ }
+
+ try save()
+ }
+
+ func moveItem(itemID: UUID, toIndex: Int) throws {
+ let activeItems = try fetchItems().filter { !$0.isCompleted }
+ .sorted { $0.sortOrder < $1.sortOrder }
+
+ guard let currentIndex = activeItems.firstIndex(where: { $0.id == itemID }) else { return }
+ guard currentIndex != toIndex else { return }
+
+ var reordered = activeItems
+ let item = reordered.remove(at: currentIndex)
+
+ // Clamp toIndex to valid range [0, reordered.count] after removal
+ let insertIndex = max(0, min(toIndex, reordered.count))
+ reordered.insert(item, at: insertIndex)
+
+ // Reassign sortOrder with gaps of 1000
+ for (index, item) in reordered.enumerated() {
+ item.sortOrder = Int64(index) * 1000
+ }
+
+ try save()
+ }
+
+ private func findItem(id: UUID) throws -> ItemEntity? {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+
+ do {
+ return try context.fetch(request).first
+ } catch {
+ throw ItemStoreError.fetchFailed(error)
+ }
+ }
+
+ func save() throws {
+ try persistenceController.save()
+ }
+}
diff --git a/Listless/Models/TaskItem.swift b/Listless/Models/TaskItem.swift
@@ -1,48 +0,0 @@
-import CoreData
-import Foundation
-
-@objc(TaskItem)
-public class TaskItem: NSManagedObject, Identifiable {
- private enum Keys {
- private static func key<Value>(_ keyPath: KeyPath<TaskItem, Value>) -> String {
- NSExpression(forKeyPath: keyPath).keyPath
- }
-
- static let id = key(\TaskItem.id as KeyPath<TaskItem, UUID>)
- static let title = key(\TaskItem.title)
- static let createdAt = key(\TaskItem.createdAt)
- static let updatedAt = key(\TaskItem.updatedAt)
- static let sortOrder = key(\TaskItem.sortOrder)
- static let completedOrder = key(\TaskItem.completedOrder)
- }
-
- @NSManaged public var id: UUID
- @NSManaged public var title: String
- @NSManaged public var createdAt: Date
- @NSManaged public var updatedAt: Date
- @NSManaged public var sortOrder: Int64
- @NSManaged public var completedOrder: Int64
-
- public var isCompleted: Bool { completedOrder > 0 }
-
- @nonobjc public class func fetchRequest() -> NSFetchRequest<TaskItem> {
- return NSFetchRequest<TaskItem>(entityName: "TaskItem")
- }
-
- public override func awakeFromInsert() {
- super.awakeFromInsert()
- setPrimitiveValue(UUID(), forKey: Keys.id)
- setPrimitiveValue(Date(), forKey: Keys.createdAt)
- setPrimitiveValue(Date(), forKey: Keys.updatedAt)
- setPrimitiveValue("", forKey: Keys.title)
- setPrimitiveValue(0, forKey: Keys.sortOrder)
- setPrimitiveValue(0, forKey: Keys.completedOrder)
- }
-
- public override func willSave() {
- super.willSave()
- if !isDeleted && changedValues().keys.contains(where: { $0 != Keys.updatedAt }) {
- setPrimitiveValue(Date(), forKey: Keys.updatedAt)
- }
- }
-}
diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift
@@ -1,201 +0,0 @@
-import CoreData
-import Foundation
-
-enum TaskStoreError: LocalizedError {
- case fetchFailed(Error)
- case saveFailed(Error)
-
- var errorDescription: String? {
- switch self {
- case .fetchFailed(let error):
- return "Failed to fetch tasks: \(error.localizedDescription)"
- case .saveFailed(let error):
- return "Failed to save changes: \(error.localizedDescription)"
- }
- }
-}
-
-@MainActor
-final class TaskStore {
- private let persistenceController: PersistenceController
- private var context: NSManagedObjectContext {
- persistenceController.viewContext
- }
-
- init(persistenceController: PersistenceController = .shared) {
- self.persistenceController = persistenceController
- }
-
- func fetchTasks() throws -> [TaskItem] {
- do {
- let activeRequest = TaskItem.fetchRequest()
- activeRequest.predicate = NSPredicate(format: "completedOrder == 0")
- activeRequest.sortDescriptors = [
- NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true)
- ]
-
- let completedRequest = TaskItem.fetchRequest()
- completedRequest.predicate = NSPredicate(format: "completedOrder > 0")
- completedRequest.sortDescriptors = [
- NSSortDescriptor(keyPath: \TaskItem.completedOrder, ascending: false)
- ]
-
- let activeTasks = try context.fetch(activeRequest)
- let completedTasks = try context.fetch(completedRequest)
-
- return activeTasks + completedTasks
- } catch {
- throw TaskStoreError.fetchFailed(error)
- }
- }
-
- func createTask(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> TaskItem {
- // Compute sort order before inserting the new object so we don't need
- // processPendingChanges() and the new task can't appear in our own query.
- let resolvedSortOrder: Int64
- if let sortOrder {
- resolvedSortOrder = sortOrder
- } else if atBeginning {
- let minOrder = try minActiveSortOrder() ?? 0
- resolvedSortOrder = minOrder - 1000
- } else {
- let maxOrder = try maxActiveSortOrder() ?? -1000
- resolvedSortOrder = maxOrder + 1000
- }
-
- let task = TaskItem(context: context)
- task.title = title
- task.sortOrder = resolvedSortOrder
-
- return task
- }
-
- private func minActiveSortOrder() throws -> Int64? {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "completedOrder == 0")
- request.sortDescriptors = [NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true)]
- request.fetchLimit = 1
- do {
- return try context.fetch(request).first?.sortOrder
- } catch {
- throw TaskStoreError.fetchFailed(error)
- }
- }
-
- private func maxActiveSortOrder() throws -> Int64? {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "completedOrder == 0")
- request.sortDescriptors = [NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: false)]
- request.fetchLimit = 1
- do {
- return try context.fetch(request).first?.sortOrder
- } catch {
- throw TaskStoreError.fetchFailed(error)
- }
- }
-
- func complete(taskID: UUID) throws {
- guard let task = try findTask(id: taskID) else { return }
-
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "completedOrder > 0")
- request.sortDescriptors = [
- NSSortDescriptor(keyPath: \TaskItem.completedOrder, ascending: false)
- ]
- request.fetchLimit = 1
- let maxOrder = (try context.fetch(request).first)?.completedOrder ?? 0
-
- task.completedOrder = maxOrder + 1
- try save()
- }
-
- func uncomplete(taskID: UUID) throws {
- guard let task = try findTask(id: taskID) else { return }
- let restoredSortOrder = task.sortOrder
- let activeTasks = try fetchTasks().filter { !$0.isCompleted && $0.id != task.id }
- let hasSortOrderConflict = activeTasks.contains { $0.sortOrder == restoredSortOrder }
-
- if hasSortOrderConflict {
- let maxSortOrder = activeTasks.map(\.sortOrder).max() ?? -1000
- task.sortOrder = maxSortOrder + 1000
- }
-
- task.completedOrder = 0
- try save()
- }
-
- func update(taskID: UUID, title: String) throws {
- guard let task = try findTask(id: taskID) else { return }
- task.title = title
- try save()
- }
-
- func updateWithoutSaving(taskID: UUID, title: String) throws {
- guard let task = try findTask(id: taskID) else { return }
- task.title = title
- // Don't save - will be saved when editing ends
- }
-
- func delete(taskID: UUID) throws {
- guard let task = try findTask(id: taskID) else { return }
- context.delete(task)
- try save()
- }
-
- func deleteMultiple(taskIDs: [UUID]) throws {
- for taskID in taskIDs {
- guard let task = try findTask(id: taskID) else { continue }
- context.delete(task)
- }
- try save()
- }
-
- func normalizeSortOrders() throws {
- let activeTasks = try fetchTasks().filter { !$0.isCompleted }
- .sorted { $0.sortOrder < $1.sortOrder }
-
- for (index, task) in activeTasks.enumerated() {
- task.sortOrder = Int64(index) * 1000
- }
-
- try save()
- }
-
- func moveTask(taskID: UUID, toIndex: Int) throws {
- let activeTasks = try fetchTasks().filter { !$0.isCompleted }
- .sorted { $0.sortOrder < $1.sortOrder }
-
- guard let currentIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else { return }
- guard currentIndex != toIndex else { return }
-
- var reordered = activeTasks
- let task = reordered.remove(at: currentIndex)
-
- // Clamp toIndex to valid range [0, reordered.count] after removal
- let insertIndex = max(0, min(toIndex, reordered.count))
- reordered.insert(task, at: insertIndex)
-
- // Reassign sortOrder with gaps of 1000
- for (index, task) in reordered.enumerated() {
- task.sortOrder = Int64(index) * 1000
- }
-
- try save()
- }
-
- private func findTask(id: UUID) throws -> TaskItem? {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
- request.fetchLimit = 1
-
- do {
- return try context.fetch(request).first
- } catch {
- throw TaskStoreError.fetchFailed(error)
- }
- }
-
- func save() throws {
- try persistenceController.save()
- }
-}
diff --git a/Listless/Sync/CloudKitErrorClassifier.swift b/Listless/Sync/CloudKitErrorClassifier.swift
@@ -44,7 +44,7 @@ enum CloudKitErrorClassifier {
}
private static func unwrap(_ error: Error) -> Error {
- if let storeError = error as? TaskStoreError {
+ if let storeError = error as? ItemStoreError {
switch storeError {
case .fetchFailed(let wrappedError), .saveFailed(let wrappedError):
return wrappedError
diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift
@@ -14,7 +14,7 @@ private final class UpdatedAtMergePolicy: NSMergePolicy {
for item in list {
guard
let conflict = item as? NSMergeConflict,
- let task = conflict.sourceObject as? TaskItem,
+ let item = conflict.sourceObject as? ItemEntity,
let objectSnapshot = conflict.objectSnapshot,
let persistedSnapshot = conflict.persistedSnapshot,
let storeUpdatedAt = persistedSnapshot["updatedAt"] as? Date
@@ -23,7 +23,7 @@ private final class UpdatedAtMergePolicy: NSMergePolicy {
continue
}
- let localUpdatedAt = (objectSnapshot["updatedAt"] as? Date) ?? task.updatedAt
+ let localUpdatedAt = (objectSnapshot["updatedAt"] as? Date) ?? item.updatedAt
// Keep local in-memory values if they are newer or equal.
guard storeUpdatedAt > localUpdatedAt else { continue }
@@ -33,9 +33,9 @@ private final class UpdatedAtMergePolicy: NSMergePolicy {
// see these keys in changedValues() and overwrite updatedAt with Date().
for (key, value) in persistedSnapshot {
if value is NSNull {
- task.setPrimitiveValue(nil, forKey: key)
+ item.setPrimitiveValue(nil, forKey: key)
} else {
- task.setPrimitiveValue(value, forKey: key)
+ item.setPrimitiveValue(value, forKey: key)
}
}
}
@@ -116,7 +116,7 @@ final class PersistenceController {
do {
try context.save()
} catch {
- throw TaskStoreError.saveFailed(error)
+ throw ItemStoreError.saveFailed(error)
}
}
}
diff --git a/ListlessMac/Extensions/ItemListView+Toolbar.swift b/ListlessMac/Extensions/ItemListView+Toolbar.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+extension ItemListView {
+ @ToolbarContentBuilder
+ var platformToolbar: some ToolbarContent {
+ ToolbarItem(placement: .automatic) {
+ Spacer()
+ }
+
+ ToolbarItemGroup(placement: .automatic) {
+ HStack {
+ if syncMonitor.hasDiagnosticsIssue {
+ Button {
+ NSApp.sendAction(
+ #selector(AppDelegate.handleShowSyncDiagnostics),
+ to: nil, from: nil
+ )
+ } label: {
+ Label("Sync Issues", systemImage: "exclamationmark.icloud")
+ }
+ .help("View sync diagnostics")
+
+ Divider()
+ }
+
+ Button {
+ createNewItem()
+ } label: {
+ Label("New Item", systemImage: "plus")
+ }
+ .help("Create a new item")
+
+ Button {
+ _ = deleteSelectedItem()
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .disabled(!canDeleteSelectionFromList)
+ .help("Delete selected item")
+
+ Divider()
+
+ Button {
+ clearCompletedItems()
+ } label: {
+ Label("Clear Completed", systemImage: "tray")
+ }
+ .disabled(completedItems.isEmpty)
+ .help("Clear all completed items")
+ }
+ }
+ }
+}
diff --git a/ListlessMac/Extensions/TaskListView+Toolbar.swift b/ListlessMac/Extensions/TaskListView+Toolbar.swift
@@ -1,53 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- @ToolbarContentBuilder
- var platformToolbar: some ToolbarContent {
- ToolbarItem(placement: .automatic) {
- Spacer()
- }
-
- ToolbarItemGroup(placement: .automatic) {
- HStack {
- if syncMonitor.hasDiagnosticsIssue {
- Button {
- NSApp.sendAction(
- #selector(AppDelegate.handleShowSyncDiagnostics),
- to: nil, from: nil
- )
- } label: {
- Label("Sync Issues", systemImage: "exclamationmark.icloud")
- }
- .help("View sync diagnostics")
-
- Divider()
- }
-
- Button {
- createNewTask()
- } label: {
- Label("New Item", systemImage: "plus")
- }
- .help("Create a new item")
-
- Button {
- _ = deleteSelectedTask()
- } label: {
- Label("Delete", systemImage: "trash")
- }
- .disabled(!canDeleteSelectionFromList)
- .help("Delete selected item")
-
- Divider()
-
- Button {
- clearCompletedTasks()
- } label: {
- Label("Clear Completed", systemImage: "tray")
- }
- .disabled(completedTasks.isEmpty)
- .help("Clear all completed tasks")
- }
- }
- }
-}
diff --git a/ListlessMac/Helpers/AppColors.swift b/ListlessMac/Helpers/AppColors.swift
@@ -1,7 +1,7 @@
import SwiftUI
extension Color {
- /// Canvas behind task rows: warm gray in light mode, default window background in dark mode.
+ /// Canvas behind item rows: warm gray in light mode, default window background in dark mode.
static let outerBackground = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? .windowBackgroundColor
diff --git a/ListlessMac/Helpers/AppCommands.swift b/ListlessMac/Helpers/AppCommands.swift
@@ -5,29 +5,29 @@ import Foundation
@MainActor
final class WindowCoordinator {
- // Actions — set by TaskListView on each relevant state change.
- var newTask: (() -> Void)?
+ // Actions — set by ItemListView on each relevant state change.
+ var newItem: (() -> Void)?
var newWindow: (() -> Void)?
- var copySelectedTask: (() -> Void)?
- var cutSelectedTask: (() -> Void)?
- var pasteAfterSelectedTask: (() -> Void)?
- var deleteSelectedTask: (() -> Void)?
- var moveSelectedTaskUp: (() -> Void)?
- var moveSelectedTaskDown: (() -> Void)?
- var markSelectedTaskCompleted: (() -> Void)?
- var selectAllTasks: (() -> Void)?
- var clearCompletedTasks: (() -> Void)?
+ var copySelectedItem: (() -> Void)?
+ var cutSelectedItem: (() -> Void)?
+ var pasteAfterSelectedItem: (() -> Void)?
+ var deleteSelectedItem: (() -> Void)?
+ var moveSelectedItemUp: (() -> Void)?
+ var moveSelectedItemDown: (() -> Void)?
+ var markSelectedItemCompleted: (() -> Void)?
+ var selectAllItems: (() -> Void)?
+ var clearCompletedItems: (() -> Void)?
// Enabled state — read by AppDelegate in menuWillOpen and validateMenuItem.
- var canSelectAllTasks = false
- var canCopySelectedTask = false
- var canCutSelectedTask = false
- var canPasteAfterSelectedTask = false
- var canDeleteSelectedTask = false
- var canMoveSelectedTaskUp = false
- var canMoveSelectedTaskDown = false
- var canMarkSelectedTaskCompleted = false
- var canClearCompletedTasks = false
+ var canSelectAllItems = false
+ var canCopySelectedItem = false
+ var canCutSelectedItem = false
+ var canPasteAfterSelectedItem = false
+ var canDeleteSelectedItem = false
+ var canMoveSelectedItemUp = false
+ var canMoveSelectedItemDown = false
+ var canMarkSelectedItemCompleted = false
+ var canClearCompletedItems = false
// Dynamic titles — read by AppDelegate in validateMenuItem.
var markCompletedTitle: String = "Mark as Complete"
diff --git a/ListlessMac/Helpers/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift
@@ -5,9 +5,9 @@ import SwiftUI
class ClickableNSTextField: NSTextField {
var onBecomeFirstResponder: (() -> Void)?
- /// The task ID this text field represents, used by the per-window
+ /// The item ID this text field represents, used by the per-window
/// `WindowCoordinator.allowedFocusTarget` check.
- var taskID: UUID?
+ var itemID: UUID?
override var acceptsFirstResponder: Bool {
// Always allow if this field is already editing.
@@ -25,7 +25,7 @@ class ClickableNSTextField: NSTextField {
{
if let allowed = coordinator.allowedFocusTarget {
// A specific target is set — only that field may accept.
- guard let taskID, case .task(let allowedID) = allowed, allowedID == taskID else {
+ guard let itemID, case .item(let allowedID) = allowed, allowedID == itemID else {
return false
}
}
@@ -37,11 +37,11 @@ class ClickableNSTextField: NSTextField {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let window,
- let taskID,
+ let itemID,
let delegate = NSApp.delegate as? AppDelegate,
let coordinator = delegate.coordinator(for: window)
else { return }
- if case .task(let allowedID) = coordinator.allowedFocusTarget, allowedID == taskID {
+ if case .item(let allowedID) = coordinator.allowedFocusTarget, allowedID == itemID {
coordinator.allowedFocusTarget = nil
window.makeFirstResponder(self)
}
@@ -63,13 +63,13 @@ class ClickableNSTextField: NSTextField {
struct ClickableTextField: NSViewRepresentable {
@Binding var text: String
let isCompleted: Bool
- let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
- var taskID: UUID? = nil
+ let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
+ var itemID: UUID? = nil
var onContentChange: ((String) -> Void)? = nil
func makeNSView(context: Context) -> ClickableNSTextField {
let textField = ClickableNSTextField()
- textField.taskID = taskID
+ textField.itemID = itemID
textField.delegate = context.coordinator
textField.isBordered = false
textField.drawsBackground = false
@@ -171,13 +171,13 @@ struct ClickableTextField: NSViewRepresentable {
@MainActor
final class Coordinator: NSObject, NSTextFieldDelegate {
@Binding var text: String
- let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
let onContentChange: ((String) -> Void)?
var editEndReason: EditEndReason = .focusLost
init(
text: Binding<String>,
- onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void,
+ onEditingChanged: @escaping (Bool, _ shouldCreateNewItem: Bool) -> Void,
onContentChange: ((String) -> Void)? = nil
) {
_text = text
@@ -215,9 +215,9 @@ struct ClickableTextField: NSViewRepresentable {
func controlTextDidEndEditing(_ obj: Notification) {
hasNotifiedEditingStarted = false
- let shouldCreateNewTask = editEndReason == .returnKey
+ let shouldCreateNewItem = editEndReason == .returnKey
editEndReason = .focusLost // Reset for next time
- onEditingChanged(false, shouldCreateNewTask)
+ onEditingChanged(false, shouldCreateNewItem)
}
func controlTextDidChange(_ obj: Notification) {
@@ -237,7 +237,7 @@ struct ClickableTextField: NSViewRepresentable {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
// Return key pressed — set the per-window allowed focus
// target to .scrollView so no text field can steal focus
- // during reconciliation. Cleared in TaskListView's outer
+ // during reconciliation. Cleared in ItemListView's outer
// onChange(of: focusedFieldBinding).
editEndReason = .returnKey
setAllowedFocusTarget(for: control.window, target: .scrollView)
diff --git a/ListlessMac/Helpers/ItemRowDragGesture.swift b/ListlessMac/Helpers/ItemRowDragGesture.swift
@@ -0,0 +1,160 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension View {
+ func itemDragGesture(
+ isActive: Bool,
+ itemID: UUID,
+ onDragStart: @escaping () -> Void,
+ onLift: @escaping () -> Void = {},
+ onLiftEnd: @escaping () -> Void = {}
+ ) -> some View {
+ self.modifier(
+ ItemRowDragGesture(
+ isActive: isActive,
+ itemID: itemID,
+ onDragStart: onDragStart,
+ onLift: onLift,
+ onLiftEnd: onLiftEnd
+ ))
+ }
+}
+
+struct ItemRowDragGesture: ViewModifier {
+ let isActive: Bool
+ let itemID: UUID
+ let onDragStart: () -> Void
+ let onLift: () -> Void
+ let onLiftEnd: () -> Void
+
+ @State private var isLifted = false
+ @State private var dragSource = DragSourceManager()
+ @State private var monitors: [Any] = []
+
+ func body(content: Content) -> some View {
+ if isActive {
+ content
+ .background { DragSourceAnchor(manager: dragSource) }
+ .simultaneousGesture(
+ LongPressGesture(minimumDuration: 0.4)
+ .onEnded { _ in
+ isLifted = true
+ onLift()
+ installMonitors()
+ }
+ )
+ } else {
+ content
+ }
+ }
+
+ private func installMonitors() {
+ removeMonitors()
+
+ dragSource.onDragEnd = {
+ endLift()
+ }
+
+ // Mouse dragged: begin drag session
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
+ if isLifted && !dragSource.isActive {
+ onDragStart()
+ dragSource.beginDrag(itemID: itemID, event: event)
+ }
+ return event
+ }!
+ )
+
+ // Mouse up: end lift if no drag session started
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
+ if isLifted && !dragSource.isActive {
+ endLift()
+ }
+ return event
+ }!
+ )
+
+ // Escape: cancel lift (during a drag session, AppKit handles
+ // Escape internally and calls draggingSession(_:endedAt:operation:))
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ if event.keyCode == 53 && isLifted && !dragSource.isActive {
+ endLift()
+ return nil
+ }
+ return event
+ }!
+ )
+ }
+
+ private func endLift() {
+ removeMonitors()
+ isLifted = false
+ dragSource.isActive = false
+ onLiftEnd()
+ }
+
+ private func removeMonitors() {
+ for monitor in monitors {
+ NSEvent.removeMonitor(monitor)
+ }
+ monitors = []
+ }
+}
+
+// MARK: - Drag Source
+
+@MainActor
+class DragSourceManager: NSObject, NSDraggingSource {
+ weak var sourceView: NSView?
+ var isActive = false
+ var onDragEnd: (() -> Void)?
+
+ func beginDrag(itemID: UUID, event: NSEvent) {
+ guard let sourceView, !isActive else { return }
+ let item = NSDraggingItem(pasteboardWriter: itemID.uuidString as NSString)
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ item.setDraggingFrame(sourceView.bounds, contents: image)
+ isActive = true
+ sourceView.beginDraggingSession(with: [item], event: event, source: self)
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession,
+ sourceOperationMaskFor context: NSDraggingContext
+ ) -> NSDragOperation {
+ context == .withinApplication ? .move : []
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession,
+ endedAt screenPoint: NSPoint,
+ operation: NSDragOperation
+ ) {
+ isActive = false
+ onDragEnd?()
+ }
+}
+
+// MARK: - Drag Source Anchor View
+
+private class DragPassthroughView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+}
+
+struct DragSourceAnchor: NSViewRepresentable {
+ let manager: DragSourceManager
+
+ func makeNSView(context: Context) -> NSView {
+ let view = DragPassthroughView()
+ manager.sourceView = view
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ manager.sourceView = nsView
+ }
+}
diff --git a/ListlessMac/Helpers/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift
@@ -1,160 +0,0 @@
-import AppKit
-import SwiftUI
-import UniformTypeIdentifiers
-
-extension View {
- func taskDragGesture(
- isActive: Bool,
- taskID: UUID,
- onDragStart: @escaping () -> Void,
- onLift: @escaping () -> Void = {},
- onLiftEnd: @escaping () -> Void = {}
- ) -> some View {
- self.modifier(
- TaskRowDragGesture(
- isActive: isActive,
- taskID: taskID,
- onDragStart: onDragStart,
- onLift: onLift,
- onLiftEnd: onLiftEnd
- ))
- }
-}
-
-struct TaskRowDragGesture: ViewModifier {
- let isActive: Bool
- let taskID: UUID
- let onDragStart: () -> Void
- let onLift: () -> Void
- let onLiftEnd: () -> Void
-
- @State private var isLifted = false
- @State private var dragSource = DragSourceManager()
- @State private var monitors: [Any] = []
-
- func body(content: Content) -> some View {
- if isActive {
- content
- .background { DragSourceAnchor(manager: dragSource) }
- .simultaneousGesture(
- LongPressGesture(minimumDuration: 0.4)
- .onEnded { _ in
- isLifted = true
- onLift()
- installMonitors()
- }
- )
- } else {
- content
- }
- }
-
- private func installMonitors() {
- removeMonitors()
-
- dragSource.onDragEnd = {
- endLift()
- }
-
- // Mouse dragged: begin drag session
- monitors.append(
- NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
- if isLifted && !dragSource.isActive {
- onDragStart()
- dragSource.beginDrag(taskID: taskID, event: event)
- }
- return event
- }!
- )
-
- // Mouse up: end lift if no drag session started
- monitors.append(
- NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
- if isLifted && !dragSource.isActive {
- endLift()
- }
- return event
- }!
- )
-
- // Escape: cancel lift (during a drag session, AppKit handles
- // Escape internally and calls draggingSession(_:endedAt:operation:))
- monitors.append(
- NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
- if event.keyCode == 53 && isLifted && !dragSource.isActive {
- endLift()
- return nil
- }
- return event
- }!
- )
- }
-
- private func endLift() {
- removeMonitors()
- isLifted = false
- dragSource.isActive = false
- onLiftEnd()
- }
-
- private func removeMonitors() {
- for monitor in monitors {
- NSEvent.removeMonitor(monitor)
- }
- monitors = []
- }
-}
-
-// MARK: - Drag Source
-
-@MainActor
-class DragSourceManager: NSObject, NSDraggingSource {
- weak var sourceView: NSView?
- var isActive = false
- var onDragEnd: (() -> Void)?
-
- func beginDrag(taskID: UUID, event: NSEvent) {
- guard let sourceView, !isActive else { return }
- let item = NSDraggingItem(pasteboardWriter: taskID.uuidString as NSString)
- let image = NSImage(size: NSSize(width: 1, height: 1))
- item.setDraggingFrame(sourceView.bounds, contents: image)
- isActive = true
- sourceView.beginDraggingSession(with: [item], event: event, source: self)
- }
-
- func draggingSession(
- _ session: NSDraggingSession,
- sourceOperationMaskFor context: NSDraggingContext
- ) -> NSDragOperation {
- context == .withinApplication ? .move : []
- }
-
- func draggingSession(
- _ session: NSDraggingSession,
- endedAt screenPoint: NSPoint,
- operation: NSDragOperation
- ) {
- isActive = false
- onDragEnd?()
- }
-}
-
-// MARK: - Drag Source Anchor View
-
-private class DragPassthroughView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? { nil }
-}
-
-struct DragSourceAnchor: NSViewRepresentable {
- let manager: DragSourceManager
-
- func makeNSView(context: Context) -> NSView {
- let view = DragPassthroughView()
- manager.sourceView = view
- return view
- }
-
- func updateNSView(_ nsView: NSView, context: Context) {
- manager.sourceView = nsView
- }
-}
diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift
@@ -76,52 +76,52 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
guard let coord = keyWindowCoordinator else { return false }
switch menuItem.action {
- case #selector(selectAll(_:)): return coord.canSelectAllTasks
- case #selector(cut(_:)): return coord.canCutSelectedTask
- case #selector(copy(_:)): return coord.canCopySelectedTask
- case #selector(paste(_:)): return coord.canPasteAfterSelectedTask
- case #selector(handleDeleteTask): return coord.canDeleteSelectedTask
- case #selector(handleMoveUp): return coord.canMoveSelectedTaskUp
- case #selector(handleMoveDown): return coord.canMoveSelectedTaskDown
+ case #selector(selectAll(_:)): return coord.canSelectAllItems
+ case #selector(cut(_:)): return coord.canCutSelectedItem
+ case #selector(copy(_:)): return coord.canCopySelectedItem
+ case #selector(paste(_:)): return coord.canPasteAfterSelectedItem
+ case #selector(handleDeleteItem): return coord.canDeleteSelectedItem
+ case #selector(handleMoveUp): return coord.canMoveSelectedItemUp
+ case #selector(handleMoveDown): return coord.canMoveSelectedItemDown
case #selector(handleMarkCompleted):
menuItem.title = coord.markCompletedTitle
- return coord.canMarkSelectedTaskCompleted
- case #selector(handleClearCompleted): return coord.canClearCompletedTasks
+ return coord.canMarkSelectedItemCompleted
+ case #selector(handleClearCompleted): return coord.canClearCompletedItems
default: return true
}
}
// MARK: - Actions
- @objc private func handleNewTask() {
+ @objc private func handleNewItem() {
if NSApp.windows.filter({ $0.isVisible }).isEmpty {
openNewWindow()
Task { @MainActor in
- keyWindowCoordinator?.newTask?()
+ keyWindowCoordinator?.newItem?()
}
} else {
- keyWindowCoordinator?.newTask?()
+ keyWindowCoordinator?.newItem?()
}
}
@objc func selectAll(_ sender: Any?) {
- keyWindowCoordinator?.selectAllTasks?()
+ keyWindowCoordinator?.selectAllItems?()
}
@objc func cut(_ sender: Any?) {
- keyWindowCoordinator?.cutSelectedTask?()
+ keyWindowCoordinator?.cutSelectedItem?()
}
@objc func copy(_ sender: Any?) {
- keyWindowCoordinator?.copySelectedTask?()
+ keyWindowCoordinator?.copySelectedItem?()
}
@objc func paste(_ sender: Any?) {
- keyWindowCoordinator?.pasteAfterSelectedTask?()
+ keyWindowCoordinator?.pasteAfterSelectedItem?()
}
- @objc private func handleDeleteTask() {
- keyWindowCoordinator?.deleteSelectedTask?()
+ @objc private func handleDeleteItem() {
+ keyWindowCoordinator?.deleteSelectedItem?()
}
@objc private func handleNewWindow() {
@@ -129,19 +129,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
@objc private func handleMoveUp() {
- keyWindowCoordinator?.moveSelectedTaskUp?()
+ keyWindowCoordinator?.moveSelectedItemUp?()
}
@objc private func handleMoveDown() {
- keyWindowCoordinator?.moveSelectedTaskDown?()
+ keyWindowCoordinator?.moveSelectedItemDown?()
}
@objc private func handleMarkCompleted() {
- keyWindowCoordinator?.markSelectedTaskCompleted?()
+ keyWindowCoordinator?.markSelectedItemCompleted?()
}
@objc private func handleClearCompleted() {
- keyWindowCoordinator?.clearCompletedTasks?()
+ keyWindowCoordinator?.clearCompletedItems?()
}
@objc func handleShowSyncDiagnostics() {
@@ -172,8 +172,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private func openNewWindow() {
let defaultContentSize = NSSize(width: 400, height: 350)
let windowCoordinator = WindowCoordinator()
- let rootView = TaskListView(
- store: TaskStore(persistenceController: persistenceController),
+ let rootView = ItemListView(
+ store: ItemStore(persistenceController: persistenceController),
syncMonitor: persistenceController.syncMonitor,
windowCoordinator: windowCoordinator
)
@@ -294,10 +294,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
mainMenu.addItem(appMenuItem)
let fileMenu = NSMenu(title: "File")
- let newTaskItem = NSMenuItem(title: "New Item", action: #selector(handleNewTask), keyEquivalent: "n")
- newTaskItem.keyEquivalentModifierMask = [.command]
- newTaskItem.target = self
- fileMenu.addItem(newTaskItem)
+ let newItemEntity = NSMenuItem(title: "New Item", action: #selector(handleNewItem), keyEquivalent: "n")
+ newItemEntity.keyEquivalentModifierMask = [.command]
+ newItemEntity.target = self
+ fileMenu.addItem(newItemEntity)
fileMenu.addItem(NSMenuItem.separator())
let newWindowItem = NSMenuItem(title: "New Window", action: #selector(handleNewWindow), keyEquivalent: "n")
@@ -326,7 +326,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
- let deleteItem = NSMenuItem(title: "Delete", action: #selector(handleDeleteTask), keyEquivalent: "\u{08}")
+ let deleteItem = NSMenuItem(title: "Delete", action: #selector(handleDeleteItem), keyEquivalent: "\u{08}")
deleteItem.keyEquivalentModifierMask = []
deleteItem.target = self
editMenu.addItem(deleteItem)
diff --git a/ListlessMac/Views/ItemListView.swift b/ListlessMac/Views/ItemListView.swift
@@ -0,0 +1,488 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct ItemListView: View, ItemListViewProtocol {
+ struct InteractionStateData {
+ var dragState: DragState = .idle
+ var liftedItemID: UUID?
+ var draftPlacement: DraftItemPlacement?
+ var draftTitle: String = ""
+ }
+
+ @AppStorage("colorTheme") private var colorThemeRaw = 0
+ private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
+
+ @Environment(\.undoManager) var undoManager
+ @Environment(\.managedObjectContext) var managedObjectContext
+
+ let store: ItemStore
+ let windowCoordinator: WindowCoordinator
+ @ObservedObject var syncMonitor: CloudKitSyncMonitor
+ @FetchRequest(
+ sortDescriptors: [],
+ animation: .default
+ )
+ var items: FetchedResults<ItemEntity>
+ @FocusState private var focusedFieldBinding: FocusField?
+ @State var fState = FocusStateData()
+ @State private var iState = InteractionStateData()
+
+ var focusedField: FocusField? {
+ get { fState.focusedField }
+ nonmutating set {
+ fState.focusedField = newValue
+ focusedFieldBinding = newValue
+ }
+ }
+
+ var dragState: DragState {
+ get { iState.dragState }
+ nonmutating set { iState.dragState = newValue }
+ }
+
+ var draftPlacement: DraftItemPlacement? {
+ get { iState.draftPlacement }
+ nonmutating set { iState.draftPlacement = newValue }
+ }
+
+ var draftTitle: String {
+ get { iState.draftTitle }
+ nonmutating set { iState.draftTitle = newValue }
+ }
+
+ var vStackSpacing: CGFloat { 0 }
+ var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
+ var selectedIndex: Int? {
+ guard let currentID = fState.selectedItemID else { return nil }
+ return activeItems.firstIndex(where: { $0.id == currentID })
+ }
+
+ var canDeleteSelectionFromList: Bool {
+ !fState.selectedItemIDs.isEmpty && focusedField == .scrollView
+ }
+
+ var canMarkSelectionCompleted: Bool {
+ guard focusedField == .scrollView else { return false }
+ let selected = allItemsInDisplayOrder.filter { fState.isItemSelected($0.id) }
+ guard !selected.isEmpty else { return false }
+ let hasActive = selected.contains { !$0.isCompleted }
+ let hasCompleted = selected.contains { $0.isCompleted }
+ return !(hasActive && hasCompleted)
+ }
+
+ var markCompletedMenuTitle: String {
+ if fState.hasMultipleSelection {
+ let hasCompleted = completedItems.contains(where: { fState.isItemSelected($0.id) })
+ return hasCompleted ? "Mark as Incomplete" : "Mark as Complete"
+ }
+ return completedItems.contains(where: { $0.id == fState.selectedItemID })
+ ? "Mark as Incomplete" : "Mark as Complete"
+ }
+
+ var canMoveSelectionUp: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard !fState.hasMultipleSelection else { return false }
+ guard let index = selectedIndex else { return false }
+ return index > 0
+ }
+
+ var canMoveSelectionDown: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard !fState.hasMultipleSelection else { return false }
+ guard let index = selectedIndex else { return false }
+ return index < activeItems.count - 1
+ }
+
+ struct MenuState: Equatable {
+ let selectedItemIDs: Set<UUID>
+ let isScrollViewFocused: Bool
+ let activeItemCount: Int
+ let completedItemCount: Int
+ let selectedIndex: Int?
+ }
+
+ var windowCoordinatorTrigger: MenuState {
+ MenuState(
+ selectedItemIDs: fState.selectedItemIDs,
+ isScrollViewFocused: focusedField == .scrollView,
+ activeItemCount: activeItems.count,
+ completedItemCount: completedItems.count,
+ selectedIndex: selectedIndex
+ )
+ }
+
+ func updateWindowCoordinator() {
+ let coord = windowCoordinator
+ coord.newItem = { createNewItem() }
+ coord.copySelectedItem = {
+ guard let itemID = fState.selectedItemID,
+ let item = items.first(where: { $0.id == itemID }) else { return }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(item.title, forType: .string)
+ }
+ coord.cutSelectedItem = {
+ guard let itemID = fState.selectedItemID,
+ let item = items.first(where: { $0.id == itemID }) else { return }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(item.title, forType: .string)
+ deleteItem(item)
+ }
+ coord.pasteAfterSelectedItem = {
+ guard let itemID = fState.selectedItemID,
+ let string = NSPasteboard.general.string(forType: .string) else { return }
+ createItem(title: string, afterItemID: itemID)
+ }
+ coord.deleteSelectedItem = { _ = deleteSelectedItem() }
+ coord.moveSelectedItemUp = { moveSelectedItemUp() }
+ coord.moveSelectedItemDown = { moveSelectedItemDown() }
+ coord.markSelectedItemCompleted = { markSelectedItemCompleted() }
+ coord.selectAllItems = {
+ fState.selectAll(displayOrder: allItemsInDisplayOrder.map(\.id))
+ }
+ coord.clearCompletedItems = { clearCompletedItems() }
+ let inNavMode = focusedField == .scrollView
+ let singleSelect = !fState.selectedItemIDs.isEmpty && !fState.hasMultipleSelection
+ coord.canSelectAllItems = inNavMode && !allItemsInDisplayOrder.isEmpty
+ coord.canCopySelectedItem = singleSelect && inNavMode
+ coord.canCutSelectedItem = singleSelect && inNavMode
+ coord.canPasteAfterSelectedItem = selectedIndex != nil && singleSelect && inNavMode
+ coord.canDeleteSelectedItem = canDeleteSelectionFromList
+ coord.canMoveSelectedItemUp = canMoveSelectionUp
+ coord.canMoveSelectedItemDown = canMoveSelectionDown
+ coord.canMarkSelectedItemCompleted = canMarkSelectionCompleted
+ coord.markCompletedTitle = markCompletedMenuTitle
+ coord.canClearCompletedItems = !completedItems.isEmpty
+ }
+
+ init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
+ self.store = store
+ self.syncMonitor = syncMonitor
+ self.windowCoordinator = windowCoordinator
+ }
+
+ func isRowLifted(_ itemID: UUID) -> Bool {
+ iState.liftedItemID == itemID || draggedItemID == itemID
+ }
+
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle _: Bool) {
+ if draftPlacement == placement {
+ draftPlacement = nil
+ }
+ draftTitle = ""
+ if fState.selectedItemID == draftID(for: placement) {
+ fState.selectedItemID = nil
+ }
+ // Resign AppKit first responder explicitly — SwiftUI's @FocusState
+ // and AppKit's responder chain are parallel systems, so setting
+ // focusedField alone may not dismiss the NSTextField.
+ NSApp.keyWindow?.makeFirstResponder(nil)
+ focusedField = nil
+ }
+
+ func didStartDrag() {}
+
+ var body: some View {
+ ScrollView {
+ ScrollViewReader { scrollProxy in
+ VStack(alignment: .leading, spacing: vStackSpacing) {
+ ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
+ let itemID = item.id
+ ItemRowView(
+ item: item,
+ itemID: itemID,
+ index: index,
+ totalItems: displayActiveItems.count,
+ isSelected: fState.isItemSelected(itemID),
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteItem($0) },
+ onSelect: {
+ let modifiers = NSApp.currentEvent?.modifierFlags ?? []
+ selectItem(
+ $0,
+ extendSelection: modifiers.contains(.shift),
+ toggleSelection: modifiers.contains(.command)
+ )
+ },
+ onStartEdit: { startEditing($0) },
+ onEndEdit: { endEditing($0, shouldCreateNewItem: $1) },
+ onPaste: { createItem(title: $0, afterItemID: itemID) }
+ )
+ .itemDragGesture(
+ isActive: !item.isCompleted,
+ itemID: item.id,
+ onDragStart: {
+ iState.liftedItemID = nil
+ startDrag(itemID: item.id)
+ },
+ onLift: { iState.liftedItemID = item.id },
+ onLiftEnd: {
+ if iState.liftedItemID == item.id { iState.liftedItemID = nil }
+ if draggedItemID == item.id { clearDragState() }
+ }
+ )
+ .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0)
+ .shadow(
+ color: isRowLifted(itemID) ? .black.opacity(0.2) : .clear,
+ radius: 8, y: 3
+ )
+ .zIndex(isRowLifted(itemID) ? 1 : 0)
+ .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(itemID))
+ .overlay {
+ if draggedItemID != nil && draggedItemID != itemID {
+ VStack(spacing: 0) {
+ // Top 1/6 - insert BEFORE
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .onDrop(
+ of: [UTType.text],
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrder(insertBefore: itemID) },
+ onPerform: { commitCurrentDrag() }
+ )
+ )
+
+ // Middle 2/3 - insert based on direction
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(4)
+ .onDrop(
+ of: [UTType.text],
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrderSmart(relativeTo: itemID) },
+ onPerform: { commitCurrentDrag() }
+ )
+ )
+
+ // Bottom 1/6 - insert AFTER
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .onDrop(
+ of: [UTType.text],
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrder(insertAfter: itemID) },
+ onPerform: { commitCurrentDrag() }
+ )
+ )
+ }
+ }
+ }
+ }
+
+ if draftPlacement == .append {
+ let total = max(1, displayActiveItems.count + 1)
+ let index = displayActiveItems.count
+ let accentColor = cachedItemColor(
+ forIndex: index, total: total, theme: colorTheme
+ )
+ let isSelected = fState.isItemSelected(draftAppendRowID)
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Image(systemName: "circle")
+ .foregroundStyle(.primary)
+ .font(.system(size: 17))
+ .fontWeight(.thin)
+ .alignmentGuide(.firstTextBaseline) { d in
+ d[VerticalAlignment.center] + 5
+ }
+
+ ClickableTextField(
+ text: Binding(
+ get: { iState.draftTitle },
+ set: { iState.draftTitle = $0 }
+ ),
+ isCompleted: false,
+ onEditingChanged: { editing, shouldCreateNewItem in
+ if editing {
+ beginDraftItemEditing(.append)
+ } else {
+ commitDraftItem(
+ shouldCreateNewItem: shouldCreateNewItem
+ )
+ }
+ },
+ itemID: draftAppendRowID
+ )
+ .focused(
+ $focusedFieldBinding,
+ equals: .item(draftAppendRowID)
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.top, 4)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .background(
+ isSelected
+ ? RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ : nil
+ )
+ .overlay(alignment: .leading) {
+ Rectangle()
+ .fill(accentColor)
+ .frame(width: 4)
+ .padding(.vertical, 1)
+ }
+ .overlay {
+ if isSelected {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .strokeBorder(accentColor.opacity(0.40), lineWidth: 2)
+ }
+ }
+ .accessibilityIdentifier("draft-row-append")
+ .id(draftAppendRowID)
+ }
+
+ ForEach(completedItems) { item in
+ let itemID = item.id
+ ItemRowView(
+ item: item,
+ itemID: itemID,
+ isSelected: fState.isItemSelected(itemID),
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteItem($0) },
+ onSelect: {
+ selectItem(
+ $0,
+ extendSelection: NSEvent.modifierFlags.contains(.shift)
+ )
+ }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .onDrop(
+ of: [UTType.text],
+ delegate: ItemReorderDropDelegate(
+ onTargeted: {},
+ onPerform: { commitCurrentDrag() }
+ )
+ )
+ .onChange(of: focusedFieldBinding) { _, newValue in
+ if case .item(let id) = (newValue ?? fState.focusedField),
+ draggedItemID == nil,
+ id != draftPrependRowID
+ {
+ withAnimation {
+ scrollProxy.scrollTo(id)
+ }
+ }
+ }
+ .onChange(of: fState.selectedItemID) { _, newID in
+ if let newID, draggedItemID == nil {
+ guard newID != draftPrependRowID else { return }
+ withAnimation {
+ scrollProxy.scrollTo(newID)
+ }
+ }
+ }
+ }
+ }
+ .onDrop(
+ of: [UTType.text],
+ delegate: ItemReorderDropDelegate(
+ onTargeted: {},
+ onPerform: { commitCurrentDrag() }
+ )
+ )
+ .background {
+ BackgroundClickMonitor {
+ handleBackgroundTap()
+ }
+ }
+ .background(Color.outerBackground)
+ .overlay {
+ if isCompletelyEmpty && draftPlacement == nil {
+ Text("Click to create")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .allowsHitTesting(false)
+ .accessibilityIdentifier("empty-state-label")
+ }
+ }
+ .focusable()
+ .focused($focusedFieldBinding, equals: .scrollView)
+ .focusEffectDisabled()
+ .accessibilityIdentifier("item-list-scrollview")
+ .keyboardNavigation([
+ ShortcutKey(key: .upArrow): navigateUp,
+ ShortcutKey(key: .downArrow): navigateDown,
+ ShortcutKey(key: .upArrow, modifiers: .shift): navigateUpExtend,
+ ShortcutKey(key: .downArrow, modifiers: .shift): navigateDownExtend,
+ ShortcutKey(key: .return): focusSelectedItem,
+ ])
+ .onAppear {
+ if focusedFieldBinding == nil {
+ focusedFieldBinding = .scrollView
+ }
+ fState.focusedField = focusedFieldBinding
+ updateWindowCoordinator()
+ }
+ .onChange(of: focusedFieldBinding) { oldValue, newValue in
+ // Clear the per-window focus gate once we've landed on
+ // a non-nil value (reconciliation is done). Keep it set
+ // while nil so the redirect below doesn't open a window
+ // for AppKit's key-view loop.
+ if newValue != nil {
+ windowCoordinator.allowedFocusTarget = nil
+ }
+ fState.focusedField = newValue
+ handleFocusChange(from: oldValue, to: newValue)
+
+ if let pending = fState.pendingFocus, newValue != pending {
+ // Focus landed somewhere other than the intended
+ // target (or went nil). Set the allowed target so
+ // the text field can claim focus in
+ // viewDidMoveToWindow if it's not yet in the
+ // hierarchy, and redirect immediately in case it is.
+ windowCoordinator.allowedFocusTarget = pending
+ fState.pendingFocus = nil
+ focusedField = pending
+ } else if newValue == nil {
+ focusedField = .scrollView
+ }
+
+ updateWindowCoordinator()
+ }
+ .onChange(of: windowCoordinatorTrigger) { _, _ in updateWindowCoordinator() }
+ .onChange(of: undoManager, initial: true) { _, newValue in
+ managedObjectContext.undoManager = newValue
+ }
+ .toolbar {
+ platformToolbar
+ }
+ .safeAreaInset(edge: .bottom) {
+ syncErrorBanner
+ }
+ }
+}
+
+private struct ItemReorderDropDelegate: DropDelegate {
+ let onTargeted: () -> Void
+ let onPerform: () -> Bool
+
+ func validateDrop(info: DropInfo) -> Bool {
+ true
+ }
+
+ func dropEntered(info: DropInfo) {
+ onTargeted()
+ }
+
+ func dropUpdated(info: DropInfo) -> DropProposal? {
+ return DropProposal(operation: .move)
+ }
+
+ func performDrop(info: DropInfo) -> Bool {
+ onPerform()
+ }
+}
diff --git a/ListlessMac/Views/ItemRowView.swift b/ListlessMac/Views/ItemRowView.swift
@@ -0,0 +1,214 @@
+import SwiftUI
+
+struct ItemRowView: View {
+ let item: ItemEntity
+ let itemID: UUID
+ let index: Int
+ let totalItems: Int
+ let isSelected: Bool
+ let onToggle: (ItemEntity) -> Void
+ let onTitleChange: (ItemEntity, String) -> Void
+ let onDelete: (ItemEntity) -> Void
+ let onSelect: (UUID) -> Void
+ let onStartEdit: (UUID) -> Void
+ let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
+ let onPaste: (String) -> Void
+ @FocusState.Binding var focusedField: FocusField?
+
+ @AppStorage("colorTheme") private var colorThemeRaw = 0
+ private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
+
+ @State private var editingTitle: String = ""
+ @State private var isCurrentlyEditing: Bool = false
+ @State private var cachedAccentColor: Color = .clear
+
+ private let horizontalPadding: CGFloat = 16
+ private let checkboxTextSpacing: CGFloat = 12
+ @ScaledMetric private var checkboxSize: CGFloat = 20
+
+ private var dividerInset: CGFloat {
+ horizontalPadding + checkboxSize + checkboxTextSpacing
+ }
+
+ @MainActor
+ private func computeAccentColor() -> Color {
+ guard !item.isCompleted else { return .clear }
+ return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
+ }
+
+ init(
+ item: ItemEntity,
+ itemID: UUID,
+ index: Int = 0,
+ totalItems: Int = 1,
+ isSelected: Bool,
+ focusedField: FocusState<FocusField?>.Binding,
+ onToggle: @escaping (ItemEntity) -> Void,
+ onTitleChange: @escaping (ItemEntity, String) -> Void,
+ onDelete: @escaping (ItemEntity) -> Void,
+ onSelect: @escaping (UUID) -> Void,
+ onStartEdit: @escaping (UUID) -> Void = { _ in },
+ onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in },
+ onPaste: @escaping (String) -> Void = { _ in }
+ ) {
+ self.item = item
+ self.itemID = itemID
+ self.index = index
+ self.totalItems = totalItems
+ self.isSelected = isSelected
+ self.onToggle = onToggle
+ self.onTitleChange = onTitleChange
+ self.onDelete = onDelete
+ self.onSelect = onSelect
+ self.onStartEdit = onStartEdit
+ self.onEndEdit = onEndEdit
+ self.onPaste = onPaste
+ _focusedField = focusedField
+ }
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Button {
+ onToggle(item)
+ } label: {
+ Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(item.isCompleted ? .secondary : .primary)
+ .font(.system(size: 17))
+ .fontWeight(.thin)
+ }
+ .buttonStyle(.borderless)
+ .alignmentGuide(.firstTextBaseline) { d in
+ d[VerticalAlignment.center] + 5
+ }
+ .accessibilityIdentifier("item-checkbox")
+ .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
+
+ ClickableTextField(
+ text: $editingTitle,
+ isCompleted: item.isCompleted,
+ onEditingChanged: { editing, shouldCreateNewItem in
+ isCurrentlyEditing = editing
+ if editing {
+ onStartEdit(itemID)
+ } else {
+ onEndEdit(itemID, shouldCreateNewItem)
+ }
+ },
+ itemID: itemID,
+ onContentChange: { newTitle in
+ guard !item.isCompleted else { return }
+ onTitleChange(item, newTitle)
+ }
+ )
+ .focused($focusedField, equals: .item(itemID))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityIdentifier(
+ isCurrentlyEditing ? "item-textfield" : "item-text-\(itemID.uuidString)")
+ }
+ .padding(.top, 4)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onSelect(itemID)
+ }
+ .background(selectionBackground)
+ .overlay(alignment: .leading) {
+ // Colored accent bar on the left edge
+ Rectangle()
+ .fill(cachedAccentColor)
+ .frame(width: 4)
+ .padding(.vertical, 1)
+ }
+ .overlay(alignment: .bottom) {
+ // Hairline border between rows, inset to align with text
+ // Only show for active (non-completed) items
+ if !item.isCompleted {
+ Rectangle()
+ .fill(.separator)
+ .frame(height: 0.5)
+ .padding(.leading, dividerInset)
+ }
+ }
+ .overlay {
+ if isSelected && !item.isCompleted {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
+ }
+ }
+ .contextMenu {
+ Button(item.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
+ onToggle(item)
+ }
+ Divider()
+ Button("Cut") {
+ cutToPasteboard()
+ }
+ Button("Copy") {
+ copyToPasteboard()
+ }
+ Button("Paste") {
+ pasteFromPasteboard()
+ }
+ .disabled(item.isCompleted)
+ Divider()
+ Button("Delete", role: .destructive) {
+ onDelete(item)
+ }
+ }
+ .onChange(of: item.title) { _, newValue in
+ // Keep editingTitle in sync with item.title when not editing
+ if !isCurrentlyEditing {
+ editingTitle = newValue
+ }
+ }
+ .onChange(of: colorThemeRaw) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .onChange(of: index) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .onChange(of: totalItems) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .onAppear {
+ // Initialize editingTitle and cache accent color (computed once)
+ editingTitle = item.title
+ cachedAccentColor = computeAccentColor()
+ }
+ }
+
+ @ViewBuilder
+ private var selectionBackground: some View {
+ if isSelected {
+ if item.isCompleted {
+ Color(nsColor: .controlBackgroundColor)
+ } else {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ }
+ }
+
+ // These context menu actions only run in navigation mode — when a row is
+ // being edited, the NSTextField field editor is first responder and its
+ // native context menu handles Cut/Copy/Paste for text directly.
+
+ private func cutToPasteboard() {
+ copyToPasteboard()
+ onDelete(item)
+ }
+
+ private func copyToPasteboard() {
+ guard !item.title.isEmpty else { return }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(item.title, forType: .string)
+ }
+
+ private func pasteFromPasteboard() {
+ guard let string = NSPasteboard.general.string(forType: .string) else { return }
+ onPaste(string)
+ }
+}
diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift
@@ -1,488 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct TaskListView: View, TaskListViewProtocol {
- struct InteractionStateData {
- var dragState: DragState = .idle
- var liftedTaskID: UUID?
- var draftPlacement: DraftTaskPlacement?
- var draftTitle: String = ""
- }
-
- @AppStorage("colorTheme") private var colorThemeRaw = 0
- private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
-
- @Environment(\.undoManager) var undoManager
- @Environment(\.managedObjectContext) var managedObjectContext
-
- let store: TaskStore
- let windowCoordinator: WindowCoordinator
- @ObservedObject var syncMonitor: CloudKitSyncMonitor
- @FetchRequest(
- sortDescriptors: [],
- animation: .default
- )
- var tasks: FetchedResults<TaskItem>
- @FocusState private var focusedFieldBinding: FocusField?
- @State var fState = FocusStateData()
- @State private var iState = InteractionStateData()
-
- var focusedField: FocusField? {
- get { fState.focusedField }
- nonmutating set {
- fState.focusedField = newValue
- focusedFieldBinding = newValue
- }
- }
-
- var dragState: DragState {
- get { iState.dragState }
- nonmutating set { iState.dragState = newValue }
- }
-
- var draftPlacement: DraftTaskPlacement? {
- get { iState.draftPlacement }
- nonmutating set { iState.draftPlacement = newValue }
- }
-
- var draftTitle: String {
- get { iState.draftTitle }
- nonmutating set { iState.draftTitle = newValue }
- }
-
- var vStackSpacing: CGFloat { 0 }
- var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
- var selectedIndex: Int? {
- guard let currentID = fState.selectedTaskID else { return nil }
- return activeTasks.firstIndex(where: { $0.id == currentID })
- }
-
- var canDeleteSelectionFromList: Bool {
- !fState.selectedTaskIDs.isEmpty && focusedField == .scrollView
- }
-
- var canMarkSelectionCompleted: Bool {
- guard focusedField == .scrollView else { return false }
- let selected = allTasksInDisplayOrder.filter { fState.isTaskSelected($0.id) }
- guard !selected.isEmpty else { return false }
- let hasActive = selected.contains { !$0.isCompleted }
- let hasCompleted = selected.contains { $0.isCompleted }
- return !(hasActive && hasCompleted)
- }
-
- var markCompletedMenuTitle: String {
- if fState.hasMultipleSelection {
- let hasCompleted = completedTasks.contains(where: { fState.isTaskSelected($0.id) })
- return hasCompleted ? "Mark as Incomplete" : "Mark as Complete"
- }
- return completedTasks.contains(where: { $0.id == fState.selectedTaskID })
- ? "Mark as Incomplete" : "Mark as Complete"
- }
-
- var canMoveSelectionUp: Bool {
- guard focusedField == .scrollView else { return false }
- guard !fState.hasMultipleSelection else { return false }
- guard let index = selectedIndex else { return false }
- return index > 0
- }
-
- var canMoveSelectionDown: Bool {
- guard focusedField == .scrollView else { return false }
- guard !fState.hasMultipleSelection else { return false }
- guard let index = selectedIndex else { return false }
- return index < activeTasks.count - 1
- }
-
- struct MenuState: Equatable {
- let selectedTaskIDs: Set<UUID>
- let isScrollViewFocused: Bool
- let activeTaskCount: Int
- let completedTaskCount: Int
- let selectedIndex: Int?
- }
-
- var windowCoordinatorTrigger: MenuState {
- MenuState(
- selectedTaskIDs: fState.selectedTaskIDs,
- isScrollViewFocused: focusedField == .scrollView,
- activeTaskCount: activeTasks.count,
- completedTaskCount: completedTasks.count,
- selectedIndex: selectedIndex
- )
- }
-
- func updateWindowCoordinator() {
- let coord = windowCoordinator
- coord.newTask = { createNewTask() }
- coord.copySelectedTask = {
- guard let taskID = fState.selectedTaskID,
- let task = tasks.first(where: { $0.id == taskID }) else { return }
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(task.title, forType: .string)
- }
- coord.cutSelectedTask = {
- guard let taskID = fState.selectedTaskID,
- let task = tasks.first(where: { $0.id == taskID }) else { return }
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(task.title, forType: .string)
- deleteTask(task)
- }
- coord.pasteAfterSelectedTask = {
- guard let taskID = fState.selectedTaskID,
- let string = NSPasteboard.general.string(forType: .string) else { return }
- createTask(title: string, afterTaskID: taskID)
- }
- coord.deleteSelectedTask = { _ = deleteSelectedTask() }
- coord.moveSelectedTaskUp = { moveSelectedTaskUp() }
- coord.moveSelectedTaskDown = { moveSelectedTaskDown() }
- coord.markSelectedTaskCompleted = { markSelectedTaskCompleted() }
- coord.selectAllTasks = {
- fState.selectAll(displayOrder: allTasksInDisplayOrder.map(\.id))
- }
- coord.clearCompletedTasks = { clearCompletedTasks() }
- let inNavMode = focusedField == .scrollView
- let singleSelect = !fState.selectedTaskIDs.isEmpty && !fState.hasMultipleSelection
- coord.canSelectAllTasks = inNavMode && !allTasksInDisplayOrder.isEmpty
- coord.canCopySelectedTask = singleSelect && inNavMode
- coord.canCutSelectedTask = singleSelect && inNavMode
- coord.canPasteAfterSelectedTask = selectedIndex != nil && singleSelect && inNavMode
- coord.canDeleteSelectedTask = canDeleteSelectionFromList
- coord.canMoveSelectedTaskUp = canMoveSelectionUp
- coord.canMoveSelectedTaskDown = canMoveSelectionDown
- coord.canMarkSelectedTaskCompleted = canMarkSelectionCompleted
- coord.markCompletedTitle = markCompletedMenuTitle
- coord.canClearCompletedTasks = !completedTasks.isEmpty
- }
-
- init(store: TaskStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
- self.store = store
- self.syncMonitor = syncMonitor
- self.windowCoordinator = windowCoordinator
- }
-
- func isRowLifted(_ taskID: UUID) -> Bool {
- iState.liftedTaskID == taskID || draggedTaskID == taskID
- }
-
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle _: Bool) {
- if draftPlacement == placement {
- draftPlacement = nil
- }
- draftTitle = ""
- if fState.selectedTaskID == draftID(for: placement) {
- fState.selectedTaskID = nil
- }
- // Resign AppKit first responder explicitly — SwiftUI's @FocusState
- // and AppKit's responder chain are parallel systems, so setting
- // focusedField alone may not dismiss the NSTextField.
- NSApp.keyWindow?.makeFirstResponder(nil)
- focusedField = nil
- }
-
- func didStartDrag() {}
-
- var body: some View {
- ScrollView {
- ScrollViewReader { scrollProxy in
- VStack(alignment: .leading, spacing: vStackSpacing) {
- ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- index: index,
- totalTasks: displayActiveTasks.count,
- isSelected: fState.isTaskSelected(taskID),
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTask($0) },
- onSelect: {
- let modifiers = NSApp.currentEvent?.modifierFlags ?? []
- selectTask(
- $0,
- extendSelection: modifiers.contains(.shift),
- toggleSelection: modifiers.contains(.command)
- )
- },
- onStartEdit: { startEditing($0) },
- onEndEdit: { endEditing($0, shouldCreateNewTask: $1) },
- onPaste: { createTask(title: $0, afterTaskID: taskID) }
- )
- .taskDragGesture(
- isActive: !task.isCompleted,
- taskID: task.id,
- onDragStart: {
- iState.liftedTaskID = nil
- startDrag(taskID: task.id)
- },
- onLift: { iState.liftedTaskID = task.id },
- onLiftEnd: {
- if iState.liftedTaskID == task.id { iState.liftedTaskID = nil }
- if draggedTaskID == task.id { clearDragState() }
- }
- )
- .scaleEffect(isRowLifted(taskID) ? 1.03 : 1.0)
- .shadow(
- color: isRowLifted(taskID) ? .black.opacity(0.2) : .clear,
- radius: 8, y: 3
- )
- .zIndex(isRowLifted(taskID) ? 1 : 0)
- .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(taskID))
- .overlay {
- if draggedTaskID != nil && draggedTaskID != taskID {
- VStack(spacing: 0) {
- // Top 1/6 - insert BEFORE
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(1)
- .onDrop(
- of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrder(insertBefore: taskID) },
- onPerform: { commitCurrentDrag() }
- )
- )
-
- // Middle 2/3 - insert based on direction
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(4)
- .onDrop(
- of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrderSmart(relativeTo: taskID) },
- onPerform: { commitCurrentDrag() }
- )
- )
-
- // Bottom 1/6 - insert AFTER
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(1)
- .onDrop(
- of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrder(insertAfter: taskID) },
- onPerform: { commitCurrentDrag() }
- )
- )
- }
- }
- }
- }
-
- if draftPlacement == .append {
- let total = max(1, displayActiveTasks.count + 1)
- let index = displayActiveTasks.count
- let accentColor = cachedTaskColor(
- forIndex: index, total: total, theme: colorTheme
- )
- let isSelected = fState.isTaskSelected(draftAppendRowID)
- HStack(alignment: .firstTextBaseline, spacing: 12) {
- Image(systemName: "circle")
- .foregroundStyle(.primary)
- .font(.system(size: 17))
- .fontWeight(.thin)
- .alignmentGuide(.firstTextBaseline) { d in
- d[VerticalAlignment.center] + 5
- }
-
- ClickableTextField(
- text: Binding(
- get: { iState.draftTitle },
- set: { iState.draftTitle = $0 }
- ),
- isCompleted: false,
- onEditingChanged: { editing, shouldCreateNewTask in
- if editing {
- beginDraftTaskEditing(.append)
- } else {
- commitDraftTask(
- shouldCreateNewTask: shouldCreateNewTask
- )
- }
- },
- taskID: draftAppendRowID
- )
- .focused(
- $focusedFieldBinding,
- equals: .task(draftAppendRowID)
- )
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.top, 4)
- .padding(.vertical, 8)
- .padding(.horizontal, 16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .background(
- isSelected
- ? RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color(nsColor: .controlBackgroundColor))
- : nil
- )
- .overlay(alignment: .leading) {
- Rectangle()
- .fill(accentColor)
- .frame(width: 4)
- .padding(.vertical, 1)
- }
- .overlay {
- if isSelected {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .strokeBorder(accentColor.opacity(0.40), lineWidth: 2)
- }
- }
- .accessibilityIdentifier("draft-row-append")
- .id(draftAppendRowID)
- }
-
- ForEach(completedTasks) { task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: fState.isTaskSelected(taskID),
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTask($0) },
- onSelect: {
- selectTask(
- $0,
- extendSelection: NSEvent.modifierFlags.contains(.shift)
- )
- }
- )
- }
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .onDrop(
- of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: {},
- onPerform: { commitCurrentDrag() }
- )
- )
- .onChange(of: focusedFieldBinding) { _, newValue in
- if case .task(let id) = (newValue ?? fState.focusedField),
- draggedTaskID == nil,
- id != draftPrependRowID
- {
- withAnimation {
- scrollProxy.scrollTo(id)
- }
- }
- }
- .onChange(of: fState.selectedTaskID) { _, newID in
- if let newID, draggedTaskID == nil {
- guard newID != draftPrependRowID else { return }
- withAnimation {
- scrollProxy.scrollTo(newID)
- }
- }
- }
- }
- }
- .onDrop(
- of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: {},
- onPerform: { commitCurrentDrag() }
- )
- )
- .background {
- BackgroundClickMonitor {
- handleBackgroundTap()
- }
- }
- .background(Color.outerBackground)
- .overlay {
- if isCompletelyEmpty && draftPlacement == nil {
- Text("Click to create")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .allowsHitTesting(false)
- .accessibilityIdentifier("empty-state-label")
- }
- }
- .focusable()
- .focused($focusedFieldBinding, equals: .scrollView)
- .focusEffectDisabled()
- .accessibilityIdentifier("task-list-scrollview")
- .keyboardNavigation([
- ShortcutKey(key: .upArrow): navigateUp,
- ShortcutKey(key: .downArrow): navigateDown,
- ShortcutKey(key: .upArrow, modifiers: .shift): navigateUpExtend,
- ShortcutKey(key: .downArrow, modifiers: .shift): navigateDownExtend,
- ShortcutKey(key: .return): focusSelectedTask,
- ])
- .onAppear {
- if focusedFieldBinding == nil {
- focusedFieldBinding = .scrollView
- }
- fState.focusedField = focusedFieldBinding
- updateWindowCoordinator()
- }
- .onChange(of: focusedFieldBinding) { oldValue, newValue in
- // Clear the per-window focus gate once we've landed on
- // a non-nil value (reconciliation is done). Keep it set
- // while nil so the redirect below doesn't open a window
- // for AppKit's key-view loop.
- if newValue != nil {
- windowCoordinator.allowedFocusTarget = nil
- }
- fState.focusedField = newValue
- handleFocusChange(from: oldValue, to: newValue)
-
- if let pending = fState.pendingFocus, newValue != pending {
- // Focus landed somewhere other than the intended
- // target (or went nil). Set the allowed target so
- // the text field can claim focus in
- // viewDidMoveToWindow if it's not yet in the
- // hierarchy, and redirect immediately in case it is.
- windowCoordinator.allowedFocusTarget = pending
- fState.pendingFocus = nil
- focusedField = pending
- } else if newValue == nil {
- focusedField = .scrollView
- }
-
- updateWindowCoordinator()
- }
- .onChange(of: windowCoordinatorTrigger) { _, _ in updateWindowCoordinator() }
- .onChange(of: undoManager, initial: true) { _, newValue in
- managedObjectContext.undoManager = newValue
- }
- .toolbar {
- platformToolbar
- }
- .safeAreaInset(edge: .bottom) {
- syncErrorBanner
- }
- }
-}
-
-private struct TaskReorderDropDelegate: DropDelegate {
- let onTargeted: () -> Void
- let onPerform: () -> Bool
-
- func validateDrop(info: DropInfo) -> Bool {
- true
- }
-
- func dropEntered(info: DropInfo) {
- onTargeted()
- }
-
- func dropUpdated(info: DropInfo) -> DropProposal? {
- return DropProposal(operation: .move)
- }
-
- func performDrop(info: DropInfo) -> Bool {
- onPerform()
- }
-}
diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/TaskRowView.swift
@@ -1,214 +0,0 @@
-import SwiftUI
-
-struct TaskRowView: View {
- let task: TaskItem
- let taskID: UUID
- let index: Int
- let totalTasks: Int
- let isSelected: Bool
- let onToggle: (TaskItem) -> Void
- let onTitleChange: (TaskItem, String) -> Void
- let onDelete: (TaskItem) -> Void
- let onSelect: (UUID) -> Void
- let onStartEdit: (UUID) -> Void
- let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void
- let onPaste: (String) -> Void
- @FocusState.Binding var focusedField: FocusField?
-
- @AppStorage("colorTheme") private var colorThemeRaw = 0
- private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
-
- @State private var editingTitle: String = ""
- @State private var isCurrentlyEditing: Bool = false
- @State private var cachedAccentColor: Color = .clear
-
- private let horizontalPadding: CGFloat = 16
- private let checkboxTextSpacing: CGFloat = 12
- @ScaledMetric private var checkboxSize: CGFloat = 20
-
- private var dividerInset: CGFloat {
- horizontalPadding + checkboxSize + checkboxTextSpacing
- }
-
- @MainActor
- private func computeAccentColor() -> Color {
- guard !task.isCompleted else { return .clear }
- return cachedTaskColor(forIndex: index, total: totalTasks, theme: colorTheme)
- }
-
- init(
- task: TaskItem,
- taskID: UUID,
- index: Int = 0,
- totalTasks: Int = 1,
- isSelected: Bool,
- focusedField: FocusState<FocusField?>.Binding,
- onToggle: @escaping (TaskItem) -> Void,
- onTitleChange: @escaping (TaskItem, String) -> Void,
- onDelete: @escaping (TaskItem) -> Void,
- onSelect: @escaping (UUID) -> Void,
- onStartEdit: @escaping (UUID) -> Void = { _ in },
- onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in },
- onPaste: @escaping (String) -> Void = { _ in }
- ) {
- self.task = task
- self.taskID = taskID
- self.index = index
- self.totalTasks = totalTasks
- self.isSelected = isSelected
- self.onToggle = onToggle
- self.onTitleChange = onTitleChange
- self.onDelete = onDelete
- self.onSelect = onSelect
- self.onStartEdit = onStartEdit
- self.onEndEdit = onEndEdit
- self.onPaste = onPaste
- _focusedField = focusedField
- }
-
- var body: some View {
- HStack(alignment: .firstTextBaseline, spacing: 12) {
- Button {
- onToggle(task)
- } label: {
- Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
- .foregroundStyle(task.isCompleted ? .secondary : .primary)
- .font(.system(size: 17))
- .fontWeight(.thin)
- }
- .buttonStyle(.borderless)
- .alignmentGuide(.firstTextBaseline) { d in
- d[VerticalAlignment.center] + 5
- }
- .accessibilityIdentifier("task-checkbox")
- .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
-
- ClickableTextField(
- text: $editingTitle,
- isCompleted: task.isCompleted,
- onEditingChanged: { editing, shouldCreateNewTask in
- isCurrentlyEditing = editing
- if editing {
- onStartEdit(taskID)
- } else {
- onEndEdit(taskID, shouldCreateNewTask)
- }
- },
- taskID: taskID,
- onContentChange: { newTitle in
- guard !task.isCompleted else { return }
- onTitleChange(task, newTitle)
- }
- )
- .focused($focusedField, equals: .task(taskID))
- .frame(maxWidth: .infinity, alignment: .leading)
- .accessibilityIdentifier(
- isCurrentlyEditing ? "task-textfield" : "task-text-\(taskID.uuidString)")
- }
- .padding(.top, 4)
- .padding(.vertical, 8)
- .padding(.horizontal, 16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .onTapGesture {
- onSelect(taskID)
- }
- .background(selectionBackground)
- .overlay(alignment: .leading) {
- // Colored accent bar on the left edge
- Rectangle()
- .fill(cachedAccentColor)
- .frame(width: 4)
- .padding(.vertical, 1)
- }
- .overlay(alignment: .bottom) {
- // Hairline border between rows, inset to align with text
- // Only show for active (non-completed) tasks
- if !task.isCompleted {
- Rectangle()
- .fill(.separator)
- .frame(height: 0.5)
- .padding(.leading, dividerInset)
- }
- }
- .overlay {
- if isSelected && !task.isCompleted {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
- }
- }
- .contextMenu {
- Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
- onToggle(task)
- }
- Divider()
- Button("Cut") {
- cutToPasteboard()
- }
- Button("Copy") {
- copyToPasteboard()
- }
- Button("Paste") {
- pasteFromPasteboard()
- }
- .disabled(task.isCompleted)
- Divider()
- Button("Delete", role: .destructive) {
- onDelete(task)
- }
- }
- .onChange(of: task.title) { _, newValue in
- // Keep editingTitle in sync with task.title when not editing
- if !isCurrentlyEditing {
- editingTitle = newValue
- }
- }
- .onChange(of: colorThemeRaw) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .onChange(of: index) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .onChange(of: totalTasks) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .onAppear {
- // Initialize editingTitle and cache accent color (computed once)
- editingTitle = task.title
- cachedAccentColor = computeAccentColor()
- }
- }
-
- @ViewBuilder
- private var selectionBackground: some View {
- if isSelected {
- if task.isCompleted {
- Color(nsColor: .controlBackgroundColor)
- } else {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- }
- }
-
- // These context menu actions only run in navigation mode — when a row is
- // being edited, the NSTextField field editor is first responder and its
- // native context menu handles Cut/Copy/Paste for text directly.
-
- private func cutToPasteboard() {
- copyToPasteboard()
- onDelete(task)
- }
-
- private func copyToPasteboard() {
- guard !task.title.isEmpty else { return }
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(task.title, forType: .string)
- }
-
- private func pasteFromPasteboard() {
- guard let string = NSPasteboard.general.string(forType: .string) else { return }
- onPaste(string)
- }
-}
diff --git a/ListlessWatch/ListlessWatchApp.swift b/ListlessWatch/ListlessWatchApp.swift
@@ -11,8 +11,8 @@ struct ListlessWatchApp: App {
var body: some Scene {
WindowGroup {
- TaskListView(
- store: TaskStore(persistenceController: persistenceController),
+ ItemListView(
+ store: ItemStore(persistenceController: persistenceController),
syncMonitor: persistenceController.syncMonitor
)
.environment(\.managedObjectContext, persistenceController.viewContext)
diff --git a/ListlessWatch/Views/ItemListView.swift b/ListlessWatch/Views/ItemListView.swift
@@ -0,0 +1,72 @@
+import CoreData
+import SwiftUI
+
+struct ItemListView: View {
+ let store: ItemStore
+ let syncMonitor: CloudKitSyncMonitor
+
+ @AppStorage("headingText") private var headingText = "Items"
+
+ @FetchRequest(
+ sortDescriptors: [
+ SortDescriptor(\ItemEntity.sortOrder, order: .forward),
+ ],
+ animation: .default
+ )
+ private var items: FetchedResults<ItemEntity>
+
+ var body: some View {
+ let activeItems = items.filter { !$0.isCompleted }
+ let completedItems = items.filter { $0.isCompleted }
+ .sorted { $0.completedOrder > $1.completedOrder }
+
+ NavigationStack {
+ Group {
+ if items.isEmpty {
+ ContentUnavailableView(
+ "No Items",
+ systemImage: "checklist",
+ description: Text("Add items on your iPhone or Mac.")
+ )
+ } else {
+ List {
+ ForEach(Array(activeItems.enumerated()), id: \.element.id) { index, item in
+ ItemRowView(
+ item: item,
+ index: index,
+ totalActive: activeItems.count,
+ onToggle: { toggleItem($0) }
+ )
+ }
+
+ if !completedItems.isEmpty {
+ Section("Completed") {
+ ForEach(completedItems) { item in
+ ItemRowView(
+ item: item,
+ index: 0,
+ totalActive: 0,
+ onToggle: { toggleItem($0) }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle(headingText)
+ }
+ }
+
+ private func toggleItem(_ item: ItemEntity) {
+ do {
+ if item.isCompleted {
+ try store.uncomplete(itemID: item.id)
+ } else {
+ try store.complete(itemID: item.id)
+ }
+ } catch {
+ // Sync monitor handles error reporting
+ }
+ }
+}
diff --git a/ListlessWatch/Views/ItemRowView.swift b/ListlessWatch/Views/ItemRowView.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+struct ItemRowView: View {
+ let item: ItemEntity
+ let index: Int
+ let totalActive: Int
+ let onToggle: (ItemEntity) -> Void
+
+ var body: some View {
+ Button {
+ onToggle(item)
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
+ .foregroundColor(
+ item.isCompleted
+ ? .secondary
+ : cachedItemColor(forIndex: index, total: totalActive)
+ )
+ .font(.system(size: 17))
+
+ Text(item.title)
+ .strikethrough(item.isCompleted)
+ .foregroundStyle(item.isCompleted ? .secondary : .primary)
+ .lineLimit(3)
+ }
+ .padding(.vertical, 4)
+ }
+ .listRowBackground(
+ item.isCompleted
+ ? AnyView(Color(white: 0.15)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)))
+ : AnyView(
+ ZStack(alignment: .top) {
+ Color(white: 0.15)
+ cachedItemColor(forIndex: index, total: totalActive)
+ .frame(height: 3)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ )
+ )
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ListlessWatch/Views/TaskListView.swift b/ListlessWatch/Views/TaskListView.swift
@@ -1,72 +0,0 @@
-import CoreData
-import SwiftUI
-
-struct TaskListView: View {
- let store: TaskStore
- let syncMonitor: CloudKitSyncMonitor
-
- @AppStorage("headingText") private var headingText = "Items"
-
- @FetchRequest(
- sortDescriptors: [
- SortDescriptor(\TaskItem.sortOrder, order: .forward),
- ],
- animation: .default
- )
- private var tasks: FetchedResults<TaskItem>
-
- var body: some View {
- let activeTasks = tasks.filter { !$0.isCompleted }
- let completedTasks = tasks.filter { $0.isCompleted }
- .sorted { $0.completedOrder > $1.completedOrder }
-
- NavigationStack {
- Group {
- if tasks.isEmpty {
- ContentUnavailableView(
- "No Items",
- systemImage: "checklist",
- description: Text("Add items on your iPhone or Mac.")
- )
- } else {
- List {
- ForEach(Array(activeTasks.enumerated()), id: \.element.id) { index, task in
- TaskRowView(
- task: task,
- index: index,
- totalActive: activeTasks.count,
- onToggle: { toggleTask($0) }
- )
- }
-
- if !completedTasks.isEmpty {
- Section("Completed") {
- ForEach(completedTasks) { task in
- TaskRowView(
- task: task,
- index: 0,
- totalActive: 0,
- onToggle: { toggleTask($0) }
- )
- }
- }
- }
- }
- }
- }
- .navigationTitle(headingText)
- }
- }
-
- private func toggleTask(_ task: TaskItem) {
- do {
- if task.isCompleted {
- try store.uncomplete(taskID: task.id)
- } else {
- try store.complete(taskID: task.id)
- }
- } catch {
- // Sync monitor handles error reporting
- }
- }
-}
diff --git a/ListlessWatch/Views/TaskRowView.swift b/ListlessWatch/Views/TaskRowView.swift
@@ -1,44 +0,0 @@
-import SwiftUI
-
-struct TaskRowView: View {
- let task: TaskItem
- let index: Int
- let totalActive: Int
- let onToggle: (TaskItem) -> Void
-
- var body: some View {
- Button {
- onToggle(task)
- } label: {
- HStack(spacing: 8) {
- Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
- .foregroundColor(
- task.isCompleted
- ? .secondary
- : cachedTaskColor(forIndex: index, total: totalActive)
- )
- .font(.system(size: 17))
-
- Text(task.title)
- .strikethrough(task.isCompleted)
- .foregroundStyle(task.isCompleted ? .secondary : .primary)
- .lineLimit(3)
- }
- .padding(.vertical, 4)
- }
- .listRowBackground(
- task.isCompleted
- ? AnyView(Color(white: 0.15)
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)))
- : AnyView(
- ZStack(alignment: .top) {
- Color(white: 0.15)
- cachedTaskColor(forIndex: index, total: totalActive)
- .frame(height: 3)
- }
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
- )
- )
- .buttonStyle(.plain)
- }
-}
diff --git a/ListlessiOS/Extensions/ItemListView+Drag.swift b/ListlessiOS/Extensions/ItemListView+Drag.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+
+extension ItemListView {
+ func handleIOSDragChanged(itemID: UUID, point: CGPoint) {
+ guard let draggedID = draggedItemID,
+ var order = visualOrder,
+ let currentIndex = order.firstIndex(of: draggedID) else { return }
+
+ let draggedFrame = layoutStorage.draggedRowFrame
+ guard draggedFrame != .zero else { return }
+
+ let threshold = draggedFrame.height * 0.2
+
+ // Swap down: finger moved past the bottom edge of the dragged row
+ if currentIndex < order.count - 1 && point.y > draggedFrame.maxY + threshold {
+ order.swapAt(currentIndex, currentIndex + 1)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ setDragOrder(order)
+ }
+ return
+ }
+
+ // Swap up: finger moved past the top edge of the dragged row
+ if currentIndex > 0 && point.y < draggedFrame.minY - threshold {
+ order.swapAt(currentIndex, currentIndex - 1)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ setDragOrder(order)
+ }
+ }
+ }
+
+ func commitIOSDrag() {
+ guard let draggedID = draggedItemID,
+ let order = visualOrder,
+ let finalIndex = order.firstIndex(of: draggedID) else {
+ clearDragState()
+ isDragging = false
+ return
+ }
+ do {
+ try store.moveItem(itemID: draggedID, toIndex: finalIndex)
+ clearDragState()
+ isDragging = false
+ } catch {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ clearDragState()
+ isDragging = false
+ }
+ presentStoreError(error)
+ }
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift b/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+extension ItemListView {
+ @ViewBuilder
+ private var settingsButton: some View {
+ Button {
+ showSettings()
+ } label: {
+ Image(systemName: "gearshape")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ var navigationHeader: some View {
+ HStack {
+ Text(headingText)
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .onTapGesture(count: 4) {
+ showSyncDiagnostics()
+ }
+ Spacer()
+ if syncMonitor.hasDiagnosticsIssue {
+ Button {
+ showSyncDiagnostics()
+ } label: {
+ Image(systemName: "exclamationmark.icloud")
+ .font(.title2)
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ if #available(iOS 26.0, *) {
+ settingsButton.buttonStyle(.glass)
+ } else {
+ settingsButton.buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 8)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ fState.selectedItemID = nil
+ focusedField = nil
+ }
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+PullGestures.swift b/ListlessiOS/Extensions/ItemListView+PullGestures.swift
@@ -0,0 +1,184 @@
+import SwiftUI
+
+extension ItemListView {
+ struct PullToCreateState {
+ enum Action {
+ case none
+ case createItem
+ case collapseIndicator
+ }
+
+ var pullOffset: CGFloat = 0
+ var indicatorOffset: CGFloat = 0
+ var isInsertionPending: Bool = false
+ var isScrollInteracting: Bool = false
+
+ private var pullStartTime: CFTimeInterval = 0
+
+ var shouldShowIndicator: Bool {
+ indicatorOffset > 0 || isInsertionPending
+ }
+
+ func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
+ isInsertionPending ? threshold : indicatorOffset
+ }
+
+ mutating func updatePullDistance(_ distance: CGFloat) {
+ // Skip duplicate writes to break onScrollGeometryChange re-layout cycles.
+ guard pullOffset != distance else { return }
+ pullOffset = distance
+ if isScrollInteracting {
+ indicatorOffset = distance
+ }
+ }
+
+ mutating func handlePhaseChange(
+ from oldPhase: ScrollPhase,
+ to newPhase: ScrollPhase,
+ pullThreshold: CGFloat,
+ flickThreshold: CGFloat
+ ) -> Action {
+ if newPhase == .interacting, oldPhase != .interacting {
+ pullStartTime = CACurrentMediaTime()
+ // Sync in case onScrollGeometryChange fired before this
+ // phase change, leaving indicatorOffset behind pullOffset.
+ indicatorOffset = pullOffset
+ }
+ isScrollInteracting = (newPhase == .interacting)
+ guard oldPhase == .interacting, newPhase != .interacting else { return .none }
+
+ let elapsed = CACurrentMediaTime() - pullStartTime
+ let isFlick = pullOffset > 0 && elapsed > 0
+ && (pullOffset / elapsed) >= flickThreshold
+
+ if pullOffset >= pullThreshold || isFlick {
+ isInsertionPending = true
+ return .createItem
+ }
+
+ isInsertionPending = false
+ return .collapseIndicator
+ }
+ }
+}
+
+private struct PullGesturesModifier: ViewModifier {
+ @Binding var pullToCreate: ItemListView.PullToCreateState
+ @Binding var pullUpOffset: CGFloat
+
+ @AppStorage("hapticsEnabled") private var hapticsEnabled = true
+ @State private var isScrollInteracting = false
+
+ let isDraftOpen: Bool
+ let hasCompletedItems: Bool
+ let pullCreateThreshold: CGFloat
+ let flickThreshold: CGFloat
+ let pullClearThreshold: CGFloat
+ let onCreateItemAtTop: () -> UUID
+ let onClearCompleted: () -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .onScrollGeometryChange(for: CGFloat.self) { geo in
+ max(0, -(geo.contentOffset.y + geo.contentInsets.top))
+ } action: { _, pullDistance in
+ pullToCreate.updatePullDistance(pullDistance)
+ }
+ .onScrollGeometryChange(for: CGFloat.self) { geo in
+ let adjustedBottomInset = geo.contentInsets.bottom - 20
+ let maxOffset = max(
+ -geo.contentInsets.top,
+ geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset
+ )
+ return max(0, geo.contentOffset.y - maxOffset)
+ } action: { _, bottomOverscroll in
+ guard hasCompletedItems, isScrollInteracting else { return }
+ pullUpOffset = bottomOverscroll
+ }
+ .onScrollPhaseChange { oldPhase, newPhase in
+ if newPhase == .interacting {
+ isScrollInteracting = true
+ }
+
+ handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
+
+ if oldPhase == .interacting, newPhase != .interacting {
+ handlePullToClearRelease()
+ isScrollInteracting = false
+ }
+ }
+ .sensoryFeedback(
+ .impact(weight: .light),
+ trigger: hapticsEnabled && !isDraftOpen && pullToCreate.pullOffset >= pullCreateThreshold
+ ) { old, new in
+ !old && new
+ }
+ .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled && pullUpOffset >= pullClearThreshold) { old, new in
+ !old && new
+ }
+ }
+
+ private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
+ guard !isDraftOpen else { return }
+ let action = pullToCreate.handlePhaseChange(
+ from: oldPhase,
+ to: newPhase,
+ pullThreshold: pullCreateThreshold,
+ flickThreshold: flickThreshold
+ )
+
+ guard oldPhase == .interacting, newPhase != .interacting else { return }
+
+ switch action {
+ case .createItem:
+ var transaction = Transaction(animation: nil)
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ _ = onCreateItemAtTop()
+ }
+ case .collapseIndicator:
+ withAnimation(.spring(response: 0.22, dampingFraction: 1.0)) {
+ pullToCreate.indicatorOffset = 0
+ }
+ case .none:
+ break
+ }
+ }
+
+ private func handlePullToClearRelease() {
+ guard hasCompletedItems, pullUpOffset >= pullClearThreshold else {
+ pullUpOffset = 0
+ return
+ }
+ pullUpOffset = 0
+ onClearCompleted()
+ }
+}
+
+extension View {
+ func pullGestures(
+ pullToCreate: Binding<ItemListView.PullToCreateState>,
+ pullUpOffset: Binding<CGFloat>,
+ isDraftOpen: Bool,
+ hasCompletedItems: Bool,
+ pullCreateThreshold: CGFloat,
+ flickThreshold: CGFloat,
+ pullClearThreshold: CGFloat,
+ onCreateItemAtTop: @escaping () -> UUID,
+ onClearCompleted: @escaping () -> Void
+ ) -> some View {
+ modifier(
+ PullGesturesModifier(
+ pullToCreate: pullToCreate,
+ pullUpOffset: pullUpOffset,
+ isDraftOpen: isDraftOpen,
+ hasCompletedItems: hasCompletedItems,
+ pullCreateThreshold: pullCreateThreshold,
+ flickThreshold: flickThreshold,
+ pullClearThreshold: pullClearThreshold,
+ onCreateItemAtTop: onCreateItemAtTop,
+ onClearCompleted: onClearCompleted
+ )
+ )
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+PullToClear.swift b/ListlessiOS/Extensions/ItemListView+PullToClear.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+
+extension ItemListView {
+ @ViewBuilder var pullToClearIndicatorRow: some View {
+ if pState.pullUpOffset > 0 && !completedItems.isEmpty {
+ PullToClearIndicator(pullOffset: pState.pullUpOffset)
+ }
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+PullToCreate.swift b/ListlessiOS/Extensions/ItemListView+PullToCreate.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+extension ItemListView {
+
+ // MARK: - Pull-to-Create Draft Helpers
+
+ func revealPhantomRow() -> UUID {
+ let itemID = draftPrependRowID
+
+ if draftPlacement != .prepend, draftPlacement != nil {
+ commitDraftItem()
+ }
+ clearDragState()
+ draftTitle = ""
+ draftPlacement = .prepend
+ fState.selectedItemID = itemID
+ fState.pendingFocus = .item(itemID)
+ focusedField = .item(itemID)
+
+ return itemID
+ }
+
+ func commitPhantomRow() {
+ commitDraftItem()
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+Toolbar.swift b/ListlessiOS/Extensions/ItemListView+Toolbar.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+extension ItemListView {
+ @ToolbarContentBuilder
+ var platformToolbar: some ToolbarContent {
+ // No toolbar on iOS - users interact with items directly
+ // (tap to toggle, swipe to delete, tap background to create)
+ ToolbarItemGroup {}
+ }
+}
diff --git a/ListlessiOS/Extensions/ItemListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift
@@ -0,0 +1,62 @@
+import SwiftUI
+
+extension ItemListView {
+
+ func deleteItemWithUndo(_ item: ItemEntity) {
+ deleteItem(item)
+ showUndoToast(message: "Item deleted")
+ }
+
+ func deleteSelectedItemWithUndo() -> KeyPress.Result {
+ guard focusedField == .scrollView else {
+ return .ignored
+ }
+ guard let currentID = fState.selectedItemID else {
+ return .handled
+ }
+ guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
+ return .handled
+ }
+ deleteItemWithUndo(item)
+ return .handled
+ }
+
+ func clearCompletedItemsWithUndo() {
+ let ids = completedItems.map(\.id)
+ guard !ids.isEmpty else { return }
+ let count = ids.count
+ managedObjectContext.undoManager?.beginUndoGrouping()
+ do {
+ try store.deleteMultiple(itemIDs: ids)
+ } catch {
+ presentStoreError(error)
+ managedObjectContext.undoManager?.endUndoGrouping()
+ return
+ }
+ managedObjectContext.undoManager?.endUndoGrouping()
+ let noun = count == 1 ? "item" : "items"
+ showUndoToast(message: "\(count) \(noun) cleared")
+ }
+
+ func showUndoToast(message: String) {
+ withAnimation {
+ iState.undoToast = UndoToastData(id: UUID(), message: message)
+ }
+ }
+
+ func performUndo() {
+ managedObjectContext.undoManager?.undo()
+ do {
+ try store.save()
+ } catch {
+ presentStoreError(error)
+ }
+ dismissUndoToast()
+ }
+
+ func dismissUndoToast() {
+ withAnimation {
+ iState.undoToast = nil
+ }
+ }
+}
diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift
@@ -1,52 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- func handleIOSDragChanged(taskID: UUID, point: CGPoint) {
- guard let draggedID = draggedTaskID,
- var order = visualOrder,
- let currentIndex = order.firstIndex(of: draggedID) else { return }
-
- let draggedFrame = layoutStorage.draggedRowFrame
- guard draggedFrame != .zero else { return }
-
- let threshold = draggedFrame.height * 0.2
-
- // Swap down: finger moved past the bottom edge of the dragged row
- if currentIndex < order.count - 1 && point.y > draggedFrame.maxY + threshold {
- order.swapAt(currentIndex, currentIndex + 1)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- setDragOrder(order)
- }
- return
- }
-
- // Swap up: finger moved past the top edge of the dragged row
- if currentIndex > 0 && point.y < draggedFrame.minY - threshold {
- order.swapAt(currentIndex, currentIndex - 1)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- setDragOrder(order)
- }
- }
- }
-
- func commitIOSDrag() {
- guard let draggedID = draggedTaskID,
- let order = visualOrder,
- let finalIndex = order.firstIndex(of: draggedID) else {
- clearDragState()
- isDragging = false
- return
- }
- do {
- try store.moveTask(taskID: draggedID, toIndex: finalIndex)
- clearDragState()
- isDragging = false
- } catch {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- clearDragState()
- isDragging = false
- }
- presentStoreError(error)
- }
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift b/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift
@@ -1,48 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- @ViewBuilder
- private var settingsButton: some View {
- Button {
- showSettings()
- } label: {
- Image(systemName: "gearshape")
- .font(.title2)
- .foregroundStyle(.secondary)
- }
- }
-
- var navigationHeader: some View {
- HStack {
- Text(headingText)
- .font(.largeTitle)
- .fontWeight(.bold)
- .onTapGesture(count: 4) {
- showSyncDiagnostics()
- }
- Spacer()
- if syncMonitor.hasDiagnosticsIssue {
- Button {
- showSyncDiagnostics()
- } label: {
- Image(systemName: "exclamationmark.icloud")
- .font(.title2)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
- if #available(iOS 26.0, *) {
- settingsButton.buttonStyle(.glass)
- } else {
- settingsButton.buttonStyle(.plain)
- }
- }
- .padding(.horizontal, 16)
- .padding(.bottom, 8)
- .contentShape(Rectangle())
- .onTapGesture {
- fState.selectedTaskID = nil
- focusedField = nil
- }
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift
@@ -1,184 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- struct PullToCreateState {
- enum Action {
- case none
- case createTask
- case collapseIndicator
- }
-
- var pullOffset: CGFloat = 0
- var indicatorOffset: CGFloat = 0
- var isInsertionPending: Bool = false
- var isScrollInteracting: Bool = false
-
- private var pullStartTime: CFTimeInterval = 0
-
- var shouldShowIndicator: Bool {
- indicatorOffset > 0 || isInsertionPending
- }
-
- func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
- isInsertionPending ? threshold : indicatorOffset
- }
-
- mutating func updatePullDistance(_ distance: CGFloat) {
- // Skip duplicate writes to break onScrollGeometryChange re-layout cycles.
- guard pullOffset != distance else { return }
- pullOffset = distance
- if isScrollInteracting {
- indicatorOffset = distance
- }
- }
-
- mutating func handlePhaseChange(
- from oldPhase: ScrollPhase,
- to newPhase: ScrollPhase,
- pullThreshold: CGFloat,
- flickThreshold: CGFloat
- ) -> Action {
- if newPhase == .interacting, oldPhase != .interacting {
- pullStartTime = CACurrentMediaTime()
- // Sync in case onScrollGeometryChange fired before this
- // phase change, leaving indicatorOffset behind pullOffset.
- indicatorOffset = pullOffset
- }
- isScrollInteracting = (newPhase == .interacting)
- guard oldPhase == .interacting, newPhase != .interacting else { return .none }
-
- let elapsed = CACurrentMediaTime() - pullStartTime
- let isFlick = pullOffset > 0 && elapsed > 0
- && (pullOffset / elapsed) >= flickThreshold
-
- if pullOffset >= pullThreshold || isFlick {
- isInsertionPending = true
- return .createTask
- }
-
- isInsertionPending = false
- return .collapseIndicator
- }
- }
-}
-
-private struct PullGesturesModifier: ViewModifier {
- @Binding var pullToCreate: TaskListView.PullToCreateState
- @Binding var pullUpOffset: CGFloat
-
- @AppStorage("hapticsEnabled") private var hapticsEnabled = true
- @State private var isScrollInteracting = false
-
- let isDraftOpen: Bool
- let hasCompletedTasks: Bool
- let pullCreateThreshold: CGFloat
- let flickThreshold: CGFloat
- let pullClearThreshold: CGFloat
- let onCreateTaskAtTop: () -> UUID
- let onClearCompleted: () -> Void
-
- func body(content: Content) -> some View {
- content
- .onScrollGeometryChange(for: CGFloat.self) { geo in
- max(0, -(geo.contentOffset.y + geo.contentInsets.top))
- } action: { _, pullDistance in
- pullToCreate.updatePullDistance(pullDistance)
- }
- .onScrollGeometryChange(for: CGFloat.self) { geo in
- let adjustedBottomInset = geo.contentInsets.bottom - 20
- let maxOffset = max(
- -geo.contentInsets.top,
- geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset
- )
- return max(0, geo.contentOffset.y - maxOffset)
- } action: { _, bottomOverscroll in
- guard hasCompletedTasks, isScrollInteracting else { return }
- pullUpOffset = bottomOverscroll
- }
- .onScrollPhaseChange { oldPhase, newPhase in
- if newPhase == .interacting {
- isScrollInteracting = true
- }
-
- handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
-
- if oldPhase == .interacting, newPhase != .interacting {
- handlePullToClearRelease()
- isScrollInteracting = false
- }
- }
- .sensoryFeedback(
- .impact(weight: .light),
- trigger: hapticsEnabled && !isDraftOpen && pullToCreate.pullOffset >= pullCreateThreshold
- ) { old, new in
- !old && new
- }
- .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled && pullUpOffset >= pullClearThreshold) { old, new in
- !old && new
- }
- }
-
- private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
- guard !isDraftOpen else { return }
- let action = pullToCreate.handlePhaseChange(
- from: oldPhase,
- to: newPhase,
- pullThreshold: pullCreateThreshold,
- flickThreshold: flickThreshold
- )
-
- guard oldPhase == .interacting, newPhase != .interacting else { return }
-
- switch action {
- case .createTask:
- var transaction = Transaction(animation: nil)
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- _ = onCreateTaskAtTop()
- }
- case .collapseIndicator:
- withAnimation(.spring(response: 0.22, dampingFraction: 1.0)) {
- pullToCreate.indicatorOffset = 0
- }
- case .none:
- break
- }
- }
-
- private func handlePullToClearRelease() {
- guard hasCompletedTasks, pullUpOffset >= pullClearThreshold else {
- pullUpOffset = 0
- return
- }
- pullUpOffset = 0
- onClearCompleted()
- }
-}
-
-extension View {
- func pullGestures(
- pullToCreate: Binding<TaskListView.PullToCreateState>,
- pullUpOffset: Binding<CGFloat>,
- isDraftOpen: Bool,
- hasCompletedTasks: Bool,
- pullCreateThreshold: CGFloat,
- flickThreshold: CGFloat,
- pullClearThreshold: CGFloat,
- onCreateTaskAtTop: @escaping () -> UUID,
- onClearCompleted: @escaping () -> Void
- ) -> some View {
- modifier(
- PullGesturesModifier(
- pullToCreate: pullToCreate,
- pullUpOffset: pullUpOffset,
- isDraftOpen: isDraftOpen,
- hasCompletedTasks: hasCompletedTasks,
- pullCreateThreshold: pullCreateThreshold,
- flickThreshold: flickThreshold,
- pullClearThreshold: pullClearThreshold,
- onCreateTaskAtTop: onCreateTaskAtTop,
- onClearCompleted: onClearCompleted
- )
- )
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/TaskListView+PullToClear.swift
@@ -1,9 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- @ViewBuilder var pullToClearIndicatorRow: some View {
- if pState.pullUpOffset > 0 && !completedTasks.isEmpty {
- PullToClearIndicator(pullOffset: pState.pullUpOffset)
- }
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift
@@ -1,26 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
-
- // MARK: - Pull-to-Create Draft Helpers
-
- func revealPhantomRow() -> UUID {
- let taskID = draftPrependRowID
-
- if draftPlacement != .prepend, draftPlacement != nil {
- commitDraftTask()
- }
- clearDragState()
- draftTitle = ""
- draftPlacement = .prepend
- fState.selectedTaskID = taskID
- fState.pendingFocus = .task(taskID)
- focusedField = .task(taskID)
-
- return taskID
- }
-
- func commitPhantomRow() {
- commitDraftTask()
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+Toolbar.swift b/ListlessiOS/Extensions/TaskListView+Toolbar.swift
@@ -1,10 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- @ToolbarContentBuilder
- var platformToolbar: some ToolbarContent {
- // No toolbar on iOS - users interact with tasks directly
- // (tap to toggle, swipe to delete, tap background to create)
- ToolbarItemGroup {}
- }
-}
diff --git a/ListlessiOS/Extensions/TaskListView+Undo.swift b/ListlessiOS/Extensions/TaskListView+Undo.swift
@@ -1,62 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
-
- func deleteTaskWithUndo(_ task: TaskItem) {
- deleteTask(task)
- showUndoToast(message: "Item deleted")
- }
-
- func deleteSelectedTaskWithUndo() -> KeyPress.Result {
- guard focusedField == .scrollView else {
- return .ignored
- }
- guard let currentID = fState.selectedTaskID else {
- return .handled
- }
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
- return .handled
- }
- deleteTaskWithUndo(task)
- return .handled
- }
-
- func clearCompletedTasksWithUndo() {
- let ids = completedTasks.map(\.id)
- guard !ids.isEmpty else { return }
- let count = ids.count
- managedObjectContext.undoManager?.beginUndoGrouping()
- do {
- try store.deleteMultiple(taskIDs: ids)
- } catch {
- presentStoreError(error)
- managedObjectContext.undoManager?.endUndoGrouping()
- return
- }
- managedObjectContext.undoManager?.endUndoGrouping()
- let noun = count == 1 ? "item" : "items"
- showUndoToast(message: "\(count) \(noun) cleared")
- }
-
- func showUndoToast(message: String) {
- withAnimation {
- iState.undoToast = UndoToastData(id: UUID(), message: message)
- }
- }
-
- func performUndo() {
- managedObjectContext.undoManager?.undo()
- do {
- try store.save()
- } catch {
- presentStoreError(error)
- }
- dismissUndoToast()
- }
-
- func dismissUndoToast() {
- withAnimation {
- iState.undoToast = nil
- }
- }
-}
diff --git a/ListlessiOS/Helpers/AppColors.swift b/ListlessiOS/Helpers/AppColors.swift
@@ -2,7 +2,7 @@ import SwiftUI
import UIKit
extension Color {
- /// Canvas behind task cards: warm gray in light mode, black in dark mode.
+ /// Canvas behind item cards: warm gray in light mode, black in dark mode.
static let outerBackground = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? .black
@@ -10,7 +10,7 @@ extension Color {
})
/// Card surface: warm tint in light mode, elevated dark gray in dark mode.
- static let taskCard = Color(uiColor: UIColor { traits in
+ static let itemCard = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.secondarySystemBackground
: .white
diff --git a/ListlessiOS/Helpers/AppCommands.swift b/ListlessiOS/Helpers/AppCommands.swift
@@ -3,7 +3,7 @@ import UIKit
// MARK: - Menu Coordinator
/// Bridges SwiftUI view state to UIKit menu items, mirroring the macOS
-/// `MenuCoordinator` pattern. `TaskListView.updateMenuCoordinator()` keeps
+/// `MenuCoordinator` pattern. `ItemListView.updateMenuCoordinator()` keeps
/// actions and enabled flags current; `KeyCaptureView` dispatches actions
/// and validates commands via the responder chain.
@MainActor
@@ -11,9 +11,9 @@ final class IOSMenuCoordinator {
static let shared = IOSMenuCoordinator()
private init() {}
- // Actions — set by TaskListView on each relevant state change.
- var newTask: (() -> Void)?
- var deleteTask: (() -> Void)?
+ // Actions — set by ItemListView on each relevant state change.
+ var newItem: (() -> Void)?
+ var deleteItem: (() -> Void)?
var moveUp: (() -> Void)?
var moveDown: (() -> Void)?
var markCompleted: (() -> Void)?
@@ -33,8 +33,8 @@ final class IOSMenuCoordinator {
/// Selectors for menu item actions routed through the responder chain.
/// `KeyCaptureView` (first responder) implements these as `@objc` methods.
enum IOSMenuSelectors {
- static let newTask = #selector(IOSMenuActions.handleNewTask)
- static let deleteTask = #selector(IOSMenuActions.handleDeleteTask)
+ static let newItem = #selector(IOSMenuActions.handleNewItem)
+ static let deleteItem = #selector(IOSMenuActions.handleDeleteItem)
static let moveUp = #selector(IOSMenuActions.handleMoveUp)
static let moveDown = #selector(IOSMenuActions.handleMoveDown)
static let markCompleted = #selector(IOSMenuActions.handleMarkCompleted)
@@ -43,8 +43,8 @@ enum IOSMenuSelectors {
/// Protocol declaring the `@objc` action methods so selectors can be
/// referenced at compile time. `KeyCaptureView` conforms to this.
@MainActor @objc protocol IOSMenuActions {
- func handleNewTask()
- func handleDeleteTask()
+ func handleNewItem()
+ func handleDeleteItem()
func handleMoveUp()
func handleMoveDown()
func handleMarkCompleted()
diff --git a/ListlessiOS/Helpers/ItemCardModifier.swift b/ListlessiOS/Helpers/ItemCardModifier.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+struct ItemCardModifier: ViewModifier {
+ var accentColor: Color
+ var isSelected: Bool
+
+ static let shape = UnevenRoundedRectangle(
+ topLeadingRadius: 0, bottomLeadingRadius: 0,
+ bottomTrailingRadius: ItemRowMetrics.trailingCornerRadius,
+ topTrailingRadius: ItemRowMetrics.trailingCornerRadius
+ )
+
+ func body(content: Content) -> some View {
+ content
+ .clipShape(Self.shape)
+ .overlay(alignment: .leading) {
+ Rectangle()
+ .fill(accentColor)
+ .frame(width: ItemRowMetrics.accentBarWidth)
+ }
+ .overlay(
+ isSelected
+ ? Self.shape
+ .strokeBorder(accentColor.opacity(0.40), lineWidth: 2)
+ : nil
+ )
+ }
+}
+
+extension View {
+ func itemCard(accentColor: Color, isSelected: Bool) -> some View {
+ modifier(ItemCardModifier(accentColor: accentColor, isSelected: isSelected))
+ }
+}
diff --git a/ListlessiOS/Helpers/ItemRowDragGesture.swift b/ListlessiOS/Helpers/ItemRowDragGesture.swift
@@ -0,0 +1,105 @@
+import SwiftUI
+
+extension View {
+ func itemDragGesture(
+ isActive: Bool,
+ itemID: UUID,
+ onDragStart: @escaping (CGFloat) -> Void,
+ onDragChanged: @escaping (CGPoint) -> Void,
+ onDragEnded: @escaping () -> Void
+ ) -> some View {
+ self.modifier(
+ ItemRowDragGesture(
+ isActive: isActive,
+ itemID: itemID,
+ onDragStart: onDragStart,
+ onDragChanged: onDragChanged,
+ onDragEnded: onDragEnded
+ ))
+ }
+}
+
+struct ItemRowDragGesture: ViewModifier {
+ let isActive: Bool
+ let itemID: UUID
+ let onDragStart: (CGFloat) -> Void
+ let onDragChanged: (CGPoint) -> Void
+ let onDragEnded: () -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .gesture(
+ SimultaneousDragGesture(
+ isActive: isActive,
+ onDragStart: onDragStart,
+ onDragChanged: onDragChanged,
+ onDragEnded: onDragEnded
+ )
+ )
+ }
+}
+
+// MARK: - iOS 26 workaround
+
+/// Uses UILongPressGestureRecognizer (minimumPressDuration: 0.4) via
+/// UIGestureRecognizerRepresentable to avoid iOS 26's child-gesture-blocks-
+/// ancestor issue. The delegate returns shouldRecognizeSimultaneouslyWith:true
+/// so the ScrollView's pan gesture is preserved.
+private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable {
+ let isActive: Bool
+ let onDragStart: (CGFloat) -> Void
+ let onDragChanged: (CGPoint) -> Void
+ let onDragEnded: () -> Void
+
+ func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
+ let recognizer = UILongPressGestureRecognizer()
+ recognizer.minimumPressDuration = 0.4
+ recognizer.delegate = context.coordinator
+ recognizer.isEnabled = isActive
+ return recognizer
+ }
+
+ func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) {
+ recognizer.isEnabled = isActive
+ }
+
+ func handleUIGestureRecognizerAction(
+ _ recognizer: UILongPressGestureRecognizer, context: Context
+ ) {
+ switch recognizer.state {
+ case .began:
+ let width = recognizer.view?.bounds.width ?? 0
+ onDragStart(width)
+
+ case .changed:
+ let location = recognizer.location(in: recognizer.view?.window)
+ onDragChanged(location)
+
+ case .ended, .cancelled:
+ onDragEnded()
+
+ default:
+ break
+ }
+ }
+
+ func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+ Coordinator()
+ }
+
+ class Coordinator: NSObject, UIGestureRecognizerDelegate {
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ true
+ }
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ otherGestureRecognizer.view is UITextView
+ }
+ }
+}
diff --git a/ListlessiOS/Helpers/ItemRowMetrics.swift b/ListlessiOS/Helpers/ItemRowMetrics.swift
@@ -0,0 +1,27 @@
+import CoreGraphics
+import SwiftUI
+import UIKit
+
+enum ItemRowMetrics {
+ /// Base item-title font size (18pt), scaled by Dynamic Type.
+ static let bodyUIK: UIFont = UIFontMetrics(forTextStyle: .body)
+ .scaledFont(for: .systemFont(ofSize: 18))
+ /// SwiftUI equivalent for use in pure SwiftUI views (e.g. PullToCreate).
+ /// Uses Dynamic Type scaling to match bodyUIK (18pt base, scaled relative to .body).
+ static let bodySUI: Font = Font(bodyUIK)
+
+ /// Hint font (17pt), scaled by Dynamic Type.
+ static let hintUIK: UIFont = UIFontMetrics(forTextStyle: .body)
+ .scaledFont(for: .systemFont(ofSize: 17))
+ /// SwiftUI equivalent for use in pure SwiftUI views.
+ /// Uses Dynamic Type scaling to match hintUIK (17pt base, scaled relative to .body).
+ static let hintSUI: Font = Font(hintUIK)
+
+ static let accentBarWidth: CGFloat = 8
+ static let trailingCornerRadius: CGFloat = 14
+ static let contentSpacing: CGFloat = 12
+ static let contentVerticalPadding: CGFloat = 14
+ static let contentHorizontalPadding: CGFloat = 16
+ static let activeLeadingPadding: CGFloat = 24
+ static let completedLeadingPadding: CGFloat = 24
+}
diff --git a/ListlessiOS/Helpers/ItemRowSwipeGesture.swift b/ListlessiOS/Helpers/ItemRowSwipeGesture.swift
@@ -0,0 +1,282 @@
+import SwiftUI
+
+extension View {
+ func itemSwipeGesture(
+ isDragging: Binding<Bool>,
+ isEditing: Bool,
+ isSwiping: Binding<Bool>,
+ swipeOffset: Binding<CGFloat>,
+ swipeDirection: Binding<ItemRowSwipeGesture.SwipeDirection>,
+ isTriggered: Binding<Bool>,
+ completeColor: Color = .green,
+ onComplete: @escaping () -> Void,
+ onDelete: @escaping () -> Void
+ ) -> some View {
+ self.modifier(
+ ItemRowSwipeGesture(
+ isDragging: isDragging,
+ isEditing: isEditing,
+ isSwiping: isSwiping,
+ swipeOffset: swipeOffset,
+ swipeDirection: swipeDirection,
+ isTriggered: isTriggered,
+ completeColor: completeColor,
+ onComplete: onComplete,
+ onDelete: onDelete
+ ))
+ }
+}
+
+struct ItemRowSwipeGesture: ViewModifier {
+ @Binding var isDragging: Bool
+ let isEditing: Bool
+ @Binding var isSwiping: Bool
+ @Binding var swipeOffset: CGFloat
+ @Binding var swipeDirection: SwipeDirection
+ @Binding var isTriggered: Bool
+ let completeColor: Color
+ let onComplete: () -> Void
+ let onDelete: () -> Void
+
+ @AppStorage("hapticsEnabled") private var hapticsEnabled = true
+ @State private var hapticTrigger = false
+ @State private var activeGestureAxis: ActiveGestureAxis = .undecided
+
+ enum SwipeDirection: Equatable {
+ case left
+ case right
+ case none
+ }
+
+ private enum ActiveGestureAxis {
+ case undecided
+ case horizontal
+ case vertical
+ }
+
+ private let completeThreshold: CGFloat = 40
+ private let deleteThreshold: CGFloat = 80
+ private let horizontalBufferPt: CGFloat = 10
+ private let offsetDamping: CGFloat = 0.9
+
+ func body(content: Content) -> some View {
+ ZStack(alignment: .leading) {
+ // Background stays in place
+ swipeBackground
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ .allowsHitTesting(false)
+
+ // Only the content moves
+ content
+ .offset(x: swipeOffset)
+ .contentShape(Rectangle())
+ }
+ .applySwipeGesture(
+ isDisabled: isDragging || isEditing,
+ onChanged: { translation in
+ guard !isDragging, !isEditing else { return }
+ updateActiveGestureAxis(
+ horizontalTranslation: translation.width,
+ verticalTranslation: abs(translation.height)
+ )
+ guard activeGestureAxis == .horizontal else { return }
+ handleDragChanged(horizontalTranslation: translation.width)
+ },
+ onEnded: {
+ handleDragEnded()
+ }
+ )
+ .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? hapticTrigger : false)
+ .onDisappear {
+ resetSwipeState()
+ }
+ }
+
+ @ViewBuilder
+ private var swipeBackground: some View {
+ if swipeDirection == .right {
+ // Complete action — accent color background
+ completeColor.opacity(backgroundOpacity(offset: swipeOffset))
+ } else if swipeDirection == .left {
+ // Delete action (red background, trash icon)
+ Color.red.opacity(backgroundOpacity(offset: swipeOffset))
+ .overlay {
+ HStack {
+ Spacer()
+ Image(systemName: "trash.fill")
+ .font(.system(size: 24))
+ .foregroundStyle(isTriggered ? .black : .white)
+ .padding(.trailing, 20)
+ }
+ }
+ }
+ }
+
+ private func handleDragChanged(horizontalTranslation: CGFloat) {
+ if horizontalTranslation > 0 {
+ swipeDirection = .right
+ } else if horizontalTranslation < 0 {
+ swipeDirection = .left
+ }
+
+ // Update offset with damping
+ swipeOffset = horizontalTranslation * offsetDamping
+
+ // Track whether threshold is currently crossed — reversible until release
+ if swipeDirection == .right {
+ isTriggered = swipeOffset >= completeThreshold
+ } else if swipeDirection == .left {
+ isTriggered = abs(swipeOffset) >= deleteThreshold
+ }
+ }
+
+ private func handleDragEnded() {
+ defer {
+ activeGestureAxis = .undecided
+ isSwiping = false
+ }
+
+ guard !isDragging else {
+ // A drag-reorder started during or after this swipe — spring back, no action.
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+ resetSwipeState()
+ }
+ return
+ }
+ if isTriggered {
+ if swipeDirection == .right {
+ // Complete: spring back and let SwiftUI animate the row to the completed section
+ triggerAction(action: onComplete)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ resetSwipeState()
+ }
+ } else {
+ // Delete: slide off screen
+ triggerAction(action: onDelete)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+ swipeOffset = -400
+ }
+ }
+ } else {
+ // Released below threshold — spring back with no action
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+ resetSwipeState()
+ }
+ }
+ }
+
+ private func triggerAction(action: () -> Void) {
+ isTriggered = true
+ hapticTrigger.toggle()
+ action()
+ }
+
+ private func resetSwipeState() {
+ swipeOffset = 0
+ swipeDirection = .none
+ isTriggered = false
+ isSwiping = false
+ activeGestureAxis = .undecided
+ }
+
+ private func backgroundOpacity(offset: CGFloat) -> CGFloat {
+ let threshold = offset >= 0 ? completeThreshold : deleteThreshold
+ return min(abs(offset) / threshold, 1.0)
+ }
+
+ private func updateActiveGestureAxis(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) {
+ guard activeGestureAxis == .undecided else { return }
+
+ if abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt {
+ activeGestureAxis = .horizontal
+ isSwiping = true
+ } else if verticalTranslation > abs(horizontalTranslation) + horizontalBufferPt {
+ activeGestureAxis = .vertical
+ }
+ }
+}
+
+// MARK: - UIGestureRecognizerRepresentable swipe gesture
+
+/// On iOS 26, `.simultaneousGesture(DragGesture())` on a child view blocks the
+/// ancestor ScrollView's scrolling. This uses a `UILongPressGestureRecognizer`
+/// (with zero press duration and infinite allowable movement) as a pan substitute,
+/// applied via `UIGestureRecognizerRepresentable`. The gesture delegate returns
+/// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
+private extension View {
+ func applySwipeGesture(
+ isDisabled: Bool,
+ onChanged: @escaping (CGSize) -> Void,
+ onEnded: @escaping () -> Void
+ ) -> some View {
+ self.gesture(
+ SimultaneousSwipeGesture(
+ onChanged: { _, translation in
+ guard !isDisabled else { return }
+ onChanged(translation)
+ },
+ onEnded: { _, _ in
+ guard !isDisabled else { return }
+ onEnded()
+ }
+ )
+ )
+ }
+}
+
+private struct SimultaneousSwipeGesture: UIGestureRecognizerRepresentable {
+ let onChanged: (UILongPressGestureRecognizer, CGSize) -> Void
+ let onEnded: (UILongPressGestureRecognizer, CGSize) -> Void
+
+ func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
+ let recognizer = UILongPressGestureRecognizer()
+ recognizer.minimumPressDuration = 0.0
+ recognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
+ recognizer.delegate = context.coordinator
+ return recognizer
+ }
+
+ func handleUIGestureRecognizerAction(
+ _ recognizer: UILongPressGestureRecognizer, context: Context
+ ) {
+ switch recognizer.state {
+ case .began:
+ context.coordinator.startLocation = recognizer.location(in: recognizer.view)
+
+ case .changed:
+ let location = recognizer.location(in: recognizer.view)
+ let translation = CGSize(
+ width: location.x - context.coordinator.startLocation.x,
+ height: location.y - context.coordinator.startLocation.y
+ )
+ onChanged(recognizer, translation)
+
+ case .ended, .cancelled:
+ let location = recognizer.location(in: recognizer.view)
+ let translation = CGSize(
+ width: location.x - context.coordinator.startLocation.x,
+ height: location.y - context.coordinator.startLocation.y
+ )
+ context.coordinator.startLocation = .zero
+ onEnded(recognizer, translation)
+
+ default:
+ break
+ }
+ }
+
+ func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+ Coordinator()
+ }
+
+ class Coordinator: NSObject, UIGestureRecognizerDelegate {
+ var startLocation: CGPoint = .zero
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ true
+ }
+ }
+}
diff --git a/ListlessiOS/Helpers/KeyCommandBridge.swift b/ListlessiOS/Helpers/KeyCommandBridge.swift
@@ -96,12 +96,12 @@ struct KeyCommandBridge: UIViewRepresentable {
// MARK: - Menu item actions (from buildMenu via responder chain)
- @objc func handleNewTask() {
- IOSMenuCoordinator.shared.newTask?()
+ @objc func handleNewItem() {
+ IOSMenuCoordinator.shared.newItem?()
}
- @objc func handleDeleteTask() {
- IOSMenuCoordinator.shared.deleteTask?()
+ @objc func handleDeleteItem() {
+ IOSMenuCoordinator.shared.deleteItem?()
}
@objc func handleMoveUp() {
@@ -120,9 +120,9 @@ struct KeyCommandBridge: UIViewRepresentable {
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
- case IOSMenuSelectors.newTask:
+ case IOSMenuSelectors.newItem:
return isActive
- case IOSMenuSelectors.deleteTask:
+ case IOSMenuSelectors.deleteItem:
return isActive && IOSMenuCoordinator.shared.canDelete
case IOSMenuSelectors.moveUp:
return isActive && IOSMenuCoordinator.shared.canMoveUp
diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift
@@ -3,12 +3,12 @@ import UIKit
/// UITextView that's always present, manages its own editing state, and expands
/// vertically to fit its content. Mirrors the interface of ClickableTextField (macOS)
-/// so TaskListView can drive both platforms through the same focusedField binding.
+/// so ItemListView can drive both platforms through the same focusedField binding.
struct TappableTextField: UIViewRepresentable {
@Binding var text: String
let isCompleted: Bool
let isDragging: Bool
- let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
var returnKeyType: UIReturnKeyType = .done
var onContentChange: ((String) -> Void)? = nil
var uiAccessibilityIdentifier: String? = nil
@@ -18,7 +18,7 @@ struct TappableTextField: UIViewRepresentable {
let textView = UITextView()
textView.accessibilityIdentifier = uiAccessibilityIdentifier
textView.delegate = context.coordinator
- textView.font = TaskRowMetrics.bodyUIK
+ textView.font = ItemRowMetrics.bodyUIK
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
@@ -29,7 +29,7 @@ struct TappableTextField: UIViewRepresentable {
let placeholder = UILabel()
placeholder.text = "Enter text"
- placeholder.font = TaskRowMetrics.bodyUIK
+ placeholder.font = ItemRowMetrics.bodyUIK
placeholder.textColor = .placeholderText
placeholder.tag = 100
placeholder.translatesAutoresizingMaskIntoConstraints = false
@@ -96,7 +96,7 @@ struct TappableTextField: UIViewRepresentable {
private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) {
var attributes: [NSAttributedString.Key: Any] = [
- .font: TaskRowMetrics.bodyUIK,
+ .font: ItemRowMetrics.bodyUIK,
.foregroundColor: isCompleted ? UIColor.secondaryLabel : UIColor.label,
]
if isCompleted {
@@ -108,7 +108,7 @@ struct TappableTextField: UIViewRepresentable {
final class Coordinator: NSObject, UITextViewDelegate {
@Binding var text: String
- let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
let onContentChange: ((String) -> Void)?
var returnKeyPressed: Bool = false
weak var textView: UITextView?
@@ -117,7 +117,7 @@ struct TappableTextField: UIViewRepresentable {
init(
text: Binding<String>,
- onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void,
+ onEditingChanged: @escaping (Bool, _ shouldCreateNewItem: Bool) -> Void,
onContentChange: ((String) -> Void)? = nil
) {
_text = text
@@ -173,19 +173,19 @@ struct TappableTextField: UIViewRepresentable {
replacementText text: String
) -> Bool {
guard text == "\n" else { return true }
- // Intercept Return: trigger new-task creation without inserting a newline.
+ // Intercept Return: trigger new-item creation without inserting a newline.
returnKeyPressed = true
onEditingChanged(false, true)
if textView.returnKeyType == .done {
- // Non-last task (or empty title): resign immediately so SwiftUI's
+ // Non-last item (or empty title): resign immediately so SwiftUI's
// focus binding update reliably clears the field on iPad, where the
// deferred focusedFieldBinding = .scrollView alone doesn't resign
// the UITextView through the hardware-keyboard focus system.
textView.resignFirstResponder()
}
- // Return false: for .next (last active task with text), the text view
+ // Return false: for .next (last active item with text), the text view
// stays first responder so SwiftUI can transfer focus atomically to the
- // newly created task's text field in the same render pass.
+ // newly created item's text field in the same render pass.
return false
}
}
diff --git a/ListlessiOS/Helpers/TaskCardModifier.swift b/ListlessiOS/Helpers/TaskCardModifier.swift
@@ -1,34 +0,0 @@
-import SwiftUI
-
-struct TaskCardModifier: ViewModifier {
- var accentColor: Color
- var isSelected: Bool
-
- static let shape = UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
-
- func body(content: Content) -> some View {
- content
- .clipShape(Self.shape)
- .overlay(alignment: .leading) {
- Rectangle()
- .fill(accentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
- }
- .overlay(
- isSelected
- ? Self.shape
- .strokeBorder(accentColor.opacity(0.40), lineWidth: 2)
- : nil
- )
- }
-}
-
-extension View {
- func taskCard(accentColor: Color, isSelected: Bool) -> some View {
- modifier(TaskCardModifier(accentColor: accentColor, isSelected: isSelected))
- }
-}
diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift
@@ -1,105 +0,0 @@
-import SwiftUI
-
-extension View {
- func taskDragGesture(
- isActive: Bool,
- taskID: UUID,
- onDragStart: @escaping (CGFloat) -> Void,
- onDragChanged: @escaping (CGPoint) -> Void,
- onDragEnded: @escaping () -> Void
- ) -> some View {
- self.modifier(
- TaskRowDragGesture(
- isActive: isActive,
- taskID: taskID,
- onDragStart: onDragStart,
- onDragChanged: onDragChanged,
- onDragEnded: onDragEnded
- ))
- }
-}
-
-struct TaskRowDragGesture: ViewModifier {
- let isActive: Bool
- let taskID: UUID
- let onDragStart: (CGFloat) -> Void
- let onDragChanged: (CGPoint) -> Void
- let onDragEnded: () -> Void
-
- func body(content: Content) -> some View {
- content
- .gesture(
- SimultaneousDragGesture(
- isActive: isActive,
- onDragStart: onDragStart,
- onDragChanged: onDragChanged,
- onDragEnded: onDragEnded
- )
- )
- }
-}
-
-// MARK: - iOS 26 workaround
-
-/// Uses UILongPressGestureRecognizer (minimumPressDuration: 0.4) via
-/// UIGestureRecognizerRepresentable to avoid iOS 26's child-gesture-blocks-
-/// ancestor issue. The delegate returns shouldRecognizeSimultaneouslyWith:true
-/// so the ScrollView's pan gesture is preserved.
-private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable {
- let isActive: Bool
- let onDragStart: (CGFloat) -> Void
- let onDragChanged: (CGPoint) -> Void
- let onDragEnded: () -> Void
-
- func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
- let recognizer = UILongPressGestureRecognizer()
- recognizer.minimumPressDuration = 0.4
- recognizer.delegate = context.coordinator
- recognizer.isEnabled = isActive
- return recognizer
- }
-
- func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) {
- recognizer.isEnabled = isActive
- }
-
- func handleUIGestureRecognizerAction(
- _ recognizer: UILongPressGestureRecognizer, context: Context
- ) {
- switch recognizer.state {
- case .began:
- let width = recognizer.view?.bounds.width ?? 0
- onDragStart(width)
-
- case .changed:
- let location = recognizer.location(in: recognizer.view?.window)
- onDragChanged(location)
-
- case .ended, .cancelled:
- onDragEnded()
-
- default:
- break
- }
- }
-
- func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
- Coordinator()
- }
-
- class Coordinator: NSObject, UIGestureRecognizerDelegate {
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- true
- }
-
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- otherGestureRecognizer.view is UITextView
- }
- }
-}
diff --git a/ListlessiOS/Helpers/TaskRowMetrics.swift b/ListlessiOS/Helpers/TaskRowMetrics.swift
@@ -1,27 +0,0 @@
-import CoreGraphics
-import SwiftUI
-import UIKit
-
-enum TaskRowMetrics {
- /// Base task-title font size (18pt), scaled by Dynamic Type.
- static let bodyUIK: UIFont = UIFontMetrics(forTextStyle: .body)
- .scaledFont(for: .systemFont(ofSize: 18))
- /// SwiftUI equivalent for use in pure SwiftUI views (e.g. PullToCreate).
- /// Uses Dynamic Type scaling to match bodyUIK (18pt base, scaled relative to .body).
- static let bodySUI: Font = Font(bodyUIK)
-
- /// Hint font (17pt), scaled by Dynamic Type.
- static let hintUIK: UIFont = UIFontMetrics(forTextStyle: .body)
- .scaledFont(for: .systemFont(ofSize: 17))
- /// SwiftUI equivalent for use in pure SwiftUI views.
- /// Uses Dynamic Type scaling to match hintUIK (17pt base, scaled relative to .body).
- static let hintSUI: Font = Font(hintUIK)
-
- static let accentBarWidth: CGFloat = 8
- static let trailingCornerRadius: CGFloat = 14
- static let contentSpacing: CGFloat = 12
- static let contentVerticalPadding: CGFloat = 14
- static let contentHorizontalPadding: CGFloat = 16
- static let activeLeadingPadding: CGFloat = 24
- static let completedLeadingPadding: CGFloat = 24
-}
diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift
@@ -1,282 +0,0 @@
-import SwiftUI
-
-extension View {
- func taskSwipeGesture(
- isDragging: Binding<Bool>,
- isEditing: Bool,
- isSwiping: Binding<Bool>,
- swipeOffset: Binding<CGFloat>,
- swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
- isTriggered: Binding<Bool>,
- completeColor: Color = .green,
- onComplete: @escaping () -> Void,
- onDelete: @escaping () -> Void
- ) -> some View {
- self.modifier(
- TaskRowSwipeGesture(
- isDragging: isDragging,
- isEditing: isEditing,
- isSwiping: isSwiping,
- swipeOffset: swipeOffset,
- swipeDirection: swipeDirection,
- isTriggered: isTriggered,
- completeColor: completeColor,
- onComplete: onComplete,
- onDelete: onDelete
- ))
- }
-}
-
-struct TaskRowSwipeGesture: ViewModifier {
- @Binding var isDragging: Bool
- let isEditing: Bool
- @Binding var isSwiping: Bool
- @Binding var swipeOffset: CGFloat
- @Binding var swipeDirection: SwipeDirection
- @Binding var isTriggered: Bool
- let completeColor: Color
- let onComplete: () -> Void
- let onDelete: () -> Void
-
- @AppStorage("hapticsEnabled") private var hapticsEnabled = true
- @State private var hapticTrigger = false
- @State private var activeGestureAxis: ActiveGestureAxis = .undecided
-
- enum SwipeDirection: Equatable {
- case left
- case right
- case none
- }
-
- private enum ActiveGestureAxis {
- case undecided
- case horizontal
- case vertical
- }
-
- private let completeThreshold: CGFloat = 40
- private let deleteThreshold: CGFloat = 80
- private let horizontalBufferPt: CGFloat = 10
- private let offsetDamping: CGFloat = 0.9
-
- func body(content: Content) -> some View {
- ZStack(alignment: .leading) {
- // Background stays in place
- swipeBackground
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .allowsHitTesting(false)
-
- // Only the content moves
- content
- .offset(x: swipeOffset)
- .contentShape(Rectangle())
- }
- .applySwipeGesture(
- isDisabled: isDragging || isEditing,
- onChanged: { translation in
- guard !isDragging, !isEditing else { return }
- updateActiveGestureAxis(
- horizontalTranslation: translation.width,
- verticalTranslation: abs(translation.height)
- )
- guard activeGestureAxis == .horizontal else { return }
- handleDragChanged(horizontalTranslation: translation.width)
- },
- onEnded: {
- handleDragEnded()
- }
- )
- .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? hapticTrigger : false)
- .onDisappear {
- resetSwipeState()
- }
- }
-
- @ViewBuilder
- private var swipeBackground: some View {
- if swipeDirection == .right {
- // Complete action — accent color background
- completeColor.opacity(backgroundOpacity(offset: swipeOffset))
- } else if swipeDirection == .left {
- // Delete action (red background, trash icon)
- Color.red.opacity(backgroundOpacity(offset: swipeOffset))
- .overlay {
- HStack {
- Spacer()
- Image(systemName: "trash.fill")
- .font(.system(size: 24))
- .foregroundStyle(isTriggered ? .black : .white)
- .padding(.trailing, 20)
- }
- }
- }
- }
-
- private func handleDragChanged(horizontalTranslation: CGFloat) {
- if horizontalTranslation > 0 {
- swipeDirection = .right
- } else if horizontalTranslation < 0 {
- swipeDirection = .left
- }
-
- // Update offset with damping
- swipeOffset = horizontalTranslation * offsetDamping
-
- // Track whether threshold is currently crossed — reversible until release
- if swipeDirection == .right {
- isTriggered = swipeOffset >= completeThreshold
- } else if swipeDirection == .left {
- isTriggered = abs(swipeOffset) >= deleteThreshold
- }
- }
-
- private func handleDragEnded() {
- defer {
- activeGestureAxis = .undecided
- isSwiping = false
- }
-
- guard !isDragging else {
- // A drag-reorder started during or after this swipe — spring back, no action.
- withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
- resetSwipeState()
- }
- return
- }
- if isTriggered {
- if swipeDirection == .right {
- // Complete: spring back and let SwiftUI animate the row to the completed section
- triggerAction(action: onComplete)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- resetSwipeState()
- }
- } else {
- // Delete: slide off screen
- triggerAction(action: onDelete)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
- swipeOffset = -400
- }
- }
- } else {
- // Released below threshold — spring back with no action
- withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
- resetSwipeState()
- }
- }
- }
-
- private func triggerAction(action: () -> Void) {
- isTriggered = true
- hapticTrigger.toggle()
- action()
- }
-
- private func resetSwipeState() {
- swipeOffset = 0
- swipeDirection = .none
- isTriggered = false
- isSwiping = false
- activeGestureAxis = .undecided
- }
-
- private func backgroundOpacity(offset: CGFloat) -> CGFloat {
- let threshold = offset >= 0 ? completeThreshold : deleteThreshold
- return min(abs(offset) / threshold, 1.0)
- }
-
- private func updateActiveGestureAxis(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) {
- guard activeGestureAxis == .undecided else { return }
-
- if abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt {
- activeGestureAxis = .horizontal
- isSwiping = true
- } else if verticalTranslation > abs(horizontalTranslation) + horizontalBufferPt {
- activeGestureAxis = .vertical
- }
- }
-}
-
-// MARK: - UIGestureRecognizerRepresentable swipe gesture
-
-/// On iOS 26, `.simultaneousGesture(DragGesture())` on a child view blocks the
-/// ancestor ScrollView's scrolling. This uses a `UILongPressGestureRecognizer`
-/// (with zero press duration and infinite allowable movement) as a pan substitute,
-/// applied via `UIGestureRecognizerRepresentable`. The gesture delegate returns
-/// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
-private extension View {
- func applySwipeGesture(
- isDisabled: Bool,
- onChanged: @escaping (CGSize) -> Void,
- onEnded: @escaping () -> Void
- ) -> some View {
- self.gesture(
- SimultaneousSwipeGesture(
- onChanged: { _, translation in
- guard !isDisabled else { return }
- onChanged(translation)
- },
- onEnded: { _, _ in
- guard !isDisabled else { return }
- onEnded()
- }
- )
- )
- }
-}
-
-private struct SimultaneousSwipeGesture: UIGestureRecognizerRepresentable {
- let onChanged: (UILongPressGestureRecognizer, CGSize) -> Void
- let onEnded: (UILongPressGestureRecognizer, CGSize) -> Void
-
- func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
- let recognizer = UILongPressGestureRecognizer()
- recognizer.minimumPressDuration = 0.0
- recognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
- recognizer.delegate = context.coordinator
- return recognizer
- }
-
- func handleUIGestureRecognizerAction(
- _ recognizer: UILongPressGestureRecognizer, context: Context
- ) {
- switch recognizer.state {
- case .began:
- context.coordinator.startLocation = recognizer.location(in: recognizer.view)
-
- case .changed:
- let location = recognizer.location(in: recognizer.view)
- let translation = CGSize(
- width: location.x - context.coordinator.startLocation.x,
- height: location.y - context.coordinator.startLocation.y
- )
- onChanged(recognizer, translation)
-
- case .ended, .cancelled:
- let location = recognizer.location(in: recognizer.view)
- let translation = CGSize(
- width: location.x - context.coordinator.startLocation.x,
- height: location.y - context.coordinator.startLocation.y
- )
- context.coordinator.startLocation = .zero
- onEnded(recognizer, translation)
-
- default:
- break
- }
- }
-
- func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
- Coordinator()
- }
-
- class Coordinator: NSObject, UIGestureRecognizerDelegate {
- var startLocation: CGPoint = .zero
-
- func gestureRecognizer(
- _ gestureRecognizer: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
- ) -> Bool {
- true
- }
- }
-}
diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift
@@ -12,7 +12,7 @@ class IOSAppDelegate: UIResponder, UIApplicationDelegate {
// File menu — New Item (⌘N)
let newItem = UIKeyCommand(
title: "New Item",
- action: IOSMenuSelectors.newTask,
+ action: IOSMenuSelectors.newItem,
input: "n",
modifierFlags: .command
)
@@ -37,7 +37,7 @@ class IOSAppDelegate: UIResponder, UIApplicationDelegate {
)
let delete = UIKeyCommand(
title: "Delete",
- action: IOSMenuSelectors.deleteTask,
+ action: IOSMenuSelectors.deleteItem,
input: "\u{8}",
modifierFlags: .command
)
@@ -73,8 +73,8 @@ struct ListlessiOSApp: App {
var body: some Scene {
WindowGroup {
- TaskListView(
- store: TaskStore(persistenceController: persistenceController),
+ ItemListView(
+ store: ItemStore(persistenceController: persistenceController),
syncMonitor: persistenceController.syncMonitor
)
.safeAreaInset(edge: .top) {
diff --git a/ListlessiOS/Views/DraftRowView.swift b/ListlessiOS/Views/DraftRowView.swift
@@ -11,7 +11,7 @@ struct DraftRowView: View {
var focusedField: FocusState<FocusField?>.Binding
var body: some View {
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
+ HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
Image(systemName: "circle")
.frame(width: 22, height: 22)
.foregroundStyle(Color.secondary)
@@ -25,17 +25,17 @@ struct DraftRowView: View {
returnKeyType: returnKeyType,
uiAccessibilityIdentifier: accessibilityIdentifier
)
- .focused(focusedField, equals: .task(draftID))
+ .focused(focusedField, equals: .item(draftID))
.frame(maxWidth: .infinity, alignment: .leading)
}
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
- .padding(.leading, TaskRowMetrics.activeLeadingPadding)
+ .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
+ .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
+ .padding(.leading, ItemRowMetrics.activeLeadingPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background {
- Color.taskCard.overlay(accentColor.opacity(0.15))
+ Color.itemCard.overlay(accentColor.opacity(0.15))
}
- .taskCard(accentColor: accentColor, isSelected: isSelected)
+ .itemCard(accentColor: accentColor, isSelected: isSelected)
}
}
diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift
@@ -0,0 +1,588 @@
+import SwiftUI
+import UIKit
+
+struct ItemListView: View, ItemListViewProtocol {
+ class LayoutStorage {
+ var draggedRowWidth: CGFloat = 0
+ var draggedRowFrame: CGRect = .zero
+ var contentBottomY: CGFloat = 0
+ }
+
+ struct InteractionStateData {
+ var dragState: DragState = .idle
+ var draftCount: Int = 0
+ var isShowingSyncDiagnostics = false
+ var isShowingSettings = false
+ var clearingItemIDs: Set<UUID> = []
+ var undoToast: UndoToastData? = nil
+ var isSwiping: Bool = false
+ var draftPlacement: DraftItemPlacement?
+ var draftTitle: String = ""
+ var fetchWorkaround: Int = 0
+ }
+
+ struct PullStateData {
+ var pullToCreate = PullToCreateState()
+ var pullUpOffset: CGFloat = 0
+
+ var headerHeight: CGFloat = 60
+ }
+
+ @AppStorage("headingText") var headingText = "Items"
+ @AppStorage("colorTheme") private var colorThemeRaw = 0
+ @AppStorage("hapticsEnabled") private var hapticsEnabled = true
+ @AppStorage("showFPSOverlay") private var showFPSOverlay = false
+ private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
+ @Environment(\.undoManager) var undoManager
+ @Environment(\.managedObjectContext) var managedObjectContext
+
+ let store: ItemStore
+ @ObservedObject var syncMonitor: CloudKitSyncMonitor
+ @FetchRequest(
+ sortDescriptors: [],
+ animation: .default
+ )
+ var items: FetchedResults<ItemEntity>
+ @FocusState private var focusedFieldBinding: FocusField?
+ @State var fState = FocusStateData()
+ @State var iState = InteractionStateData()
+ @State var pState = PullStateData()
+ @State var isDragging = false
+ @State var layoutStorage = LayoutStorage()
+
+ var focusedField: FocusField? {
+ get { fState.focusedField }
+ nonmutating set {
+ fState.focusedField = newValue
+ focusedFieldBinding = newValue
+ }
+ }
+
+ var dragState: DragState {
+ get { iState.dragState }
+ nonmutating set { iState.dragState = newValue }
+ }
+
+ var draftPlacement: DraftItemPlacement? {
+ get { iState.draftPlacement }
+ nonmutating set {
+ if newValue != nil, iState.draftPlacement == nil {
+ iState.draftCount += 1
+ }
+ iState.draftPlacement = newValue
+ }
+ }
+
+ var draftTitle: String {
+ get { iState.draftTitle }
+ nonmutating set { iState.draftTitle = newValue }
+ }
+
+ private var isPrependDraftVisible: Bool {
+ draftPlacement == .prepend
+ }
+
+ private var isAppendDraftVisible: Bool {
+ draftPlacement == .append
+ }
+
+ var draftTitleBinding: Binding<String> {
+ Binding(
+ get: { iState.draftTitle },
+ set: { iState.draftTitle = $0 }
+ )
+ }
+
+ private var isDraggingStateBinding: Binding<Bool> {
+ $isDragging
+ }
+
+ private var pullToCreateStateBinding: Binding<PullToCreateState> {
+ Binding(
+ get: { pState.pullToCreate },
+ set: { pState.pullToCreate = $0 }
+ )
+ }
+
+ private var pullUpOffsetStateBinding: Binding<CGFloat> {
+ Binding(
+ get: { pState.pullUpOffset },
+ set: { pState.pullUpOffset = $0 }
+ )
+ }
+
+ private var isShowingSyncDiagnosticsStateBinding: Binding<Bool> {
+ Binding(
+ get: { iState.isShowingSyncDiagnostics },
+ set: { iState.isShowingSyncDiagnostics = $0 }
+ )
+ }
+
+ private var selectedIndex: Int? {
+ guard let currentID = fState.selectedItemID else { return nil }
+ return activeItems.firstIndex(where: { $0.id == currentID })
+ }
+
+ private var canMoveSelectionUp: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard let index = selectedIndex else { return false }
+ return index > 0
+ }
+
+ private var canMoveSelectionDown: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard let index = selectedIndex else { return false }
+ return index < activeItems.count - 1
+ }
+
+ private struct MenuState: Equatable {
+ let selectedItemID: UUID?
+ let isScrollViewFocused: Bool
+ let activeItemCount: Int
+ let completedItemCount: Int
+ let selectedIndex: Int?
+ }
+
+ private var menuCoordinatorTrigger: MenuState {
+ MenuState(
+ selectedItemID: fState.selectedItemID,
+ isScrollViewFocused: focusedField == .scrollView,
+ activeItemCount: activeItems.count,
+ completedItemCount: completedItems.count,
+ selectedIndex: selectedIndex
+ )
+ }
+
+ func updateMenuCoordinator() {
+ let coord = IOSMenuCoordinator.shared
+ coord.newItem = { createNewItem() }
+ coord.deleteItem = { _ = deleteSelectedItemWithUndo() }
+ coord.moveUp = { moveSelectedItemUp() }
+ coord.moveDown = { moveSelectedItemDown() }
+ coord.markCompleted = { markSelectedItemCompleted() }
+ let inNavMode = focusedField == .scrollView
+ coord.canDelete = fState.selectedItemID != nil && inNavMode
+ coord.canMoveUp = canMoveSelectionUp
+ coord.canMoveDown = canMoveSelectionDown
+ coord.canMarkCompleted = fState.selectedItemID != nil && inNavMode
+ coord.markCompletedTitle = completedItems.contains(where: { $0.id == fState.selectedItemID })
+ ? "Mark as Incomplete" : "Mark as Complete"
+ }
+
+ var vStackSpacing: CGFloat { 0 }
+ var rowGap: CGFloat { 12 }
+ var pullCreateThreshold: CGFloat { 70 }
+ var flickThreshold: CGFloat { 500 }
+ var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
+
+ init(store: ItemStore, syncMonitor: CloudKitSyncMonitor) {
+ self.store = store
+ self.syncMonitor = syncMonitor
+ }
+
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
+ let clear: () -> Void = {
+ if draftPlacement == placement {
+ draftPlacement = nil
+ }
+ draftTitle = ""
+ if fState.selectedItemID == draftID(for: placement) {
+ fState.selectedItemID = nil
+ }
+
+ guard placement == .prepend else { return }
+
+ var state = pState.pullToCreate
+ state.isInsertionPending = false
+ state.indicatorOffset = 0
+ pState.pullToCreate = state
+ }
+
+ if placement == .prepend, !hasTitle {
+ withAnimation(.spring(response: 0.24, dampingFraction: 1.0)) {
+ clear()
+ }
+ } else if placement == .prepend {
+ var transaction = Transaction(animation: nil)
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ clear()
+ }
+ } else {
+ clear()
+ }
+
+ if placement == .prepend || !hasTitle {
+ focusedField = nil
+ }
+ }
+
+ func didStartDrag() {
+ isDragging = true
+ if hapticsEnabled {
+ let generator = UIImpactFeedbackGenerator(style: .light)
+ generator.impactOccurred()
+ }
+ }
+
+ func showSyncDiagnostics() {
+ iState.isShowingSyncDiagnostics = true
+ }
+
+ func showSettings() {
+ iState.isShowingSettings = true
+ }
+
+ private func dragScaleEffect() -> CGFloat {
+ let liftPoints: CGFloat = 20
+ let width = layoutStorage.draggedRowWidth
+ guard width > 0 else { return 1.05 }
+ return (width + liftPoints) / width
+ }
+
+ /// Combined indicator and phantom entry row sharing the same VStack slot.
+ /// The phantom's UITextView is created while the indicator is visible
+ /// (during the pull), so it's ready when the user releases.
+ @ViewBuilder var pullToCreateIndicatorRow: some View {
+ let pullOffset = pState.pullToCreate.pullOffset
+ let indicatorHeight = PullToCreateIndicator.indicatorHeight
+ let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset(
+ threshold: pullCreateThreshold
+ )
+ let frameHeight: CGFloat = isPrependDraftVisible
+ ? 0
+ : min(pullOffset, indicatorHeight + rowGap)
+ let opacity: Double = isPrependDraftVisible || pullOffset <= 0 ? 0 : 1
+ PullToCreateIndicator(
+ pullOffset: max(0, indicatorDisplayOffset),
+ threshold: pullCreateThreshold
+ )
+ .frame(
+ height: frameHeight,
+ alignment: .top
+ )
+ .opacity(opacity)
+ }
+
+ /// The draft row content styled to match a item row. Controlled by the
+ /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility.
+ @ViewBuilder private var draftPrependRow: some View {
+ DraftRowView(
+ accentColor: itemColor(
+ forIndex: 0, total: max(1, displayActiveItems.count + 1), theme: colorTheme
+ ),
+ isSelected: fState.selectedItemID == draftPrependRowID,
+ draftID: draftPrependRowID,
+ title: draftTitleBinding,
+ onEditingChanged: { editing, _ in
+ DispatchQueue.main.async {
+ if editing {
+ beginDraftItemEditing(.prepend)
+ } else {
+ commitDraftItem()
+ }
+ }
+ },
+ returnKeyType: .done,
+ accessibilityIdentifier: "draft-row-prepend",
+ focusedField: $focusedFieldBinding
+ )
+ }
+
+ @ViewBuilder private var draftAppendRow: some View {
+ if isAppendDraftVisible {
+ DraftRowView(
+ accentColor: itemColor(
+ forIndex: displayActiveItems.count,
+ total: max(1, displayActiveItems.count + 1),
+ theme: colorTheme
+ ),
+ isSelected: fState.selectedItemID == draftAppendRowID,
+ draftID: draftAppendRowID,
+ title: draftTitleBinding,
+ onEditingChanged: { editing, shouldCreateNewItem in
+ DispatchQueue.main.async {
+ if editing {
+ beginDraftItemEditing(.append)
+ } else {
+ commitDraftItem(
+ shouldCreateNewItem: shouldCreateNewItem
+ )
+ }
+ }
+ },
+ returnKeyType: draftTitle.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).isEmpty ? .done : .next,
+ accessibilityIdentifier: "draft-row-append",
+ focusedField: $focusedFieldBinding
+ )
+ .padding(.bottom, rowGap)
+ .id(draftAppendRowID)
+ }
+ }
+
+ @ViewBuilder private var itemRows: some View {
+ let _ = iState.fetchWorkaround
+ let draftOffset = isPrependDraftVisible ? 1 : 0
+ let draftTotal = draftPlacement != nil ? 1 : 0
+ ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
+ let itemID = item.id
+ ItemRowView(
+ item: item,
+ itemID: itemID,
+ index: index + draftOffset,
+ totalItems: displayActiveItems.count + draftTotal,
+ isSelected: fState.selectedItemID == itemID,
+ isDragging: isDraggingStateBinding,
+ isSwiping: $iState.isSwiping,
+ isLastActiveItem: index == displayActiveItems.count - 1,
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteItemWithUndo($0) },
+ onSelect: { selectItem($0) },
+ onStartEdit: { startEditing($0) },
+ onEndEdit: {
+ if fState.selectedItemID == $0 {
+ fState.selectedItemID = nil
+ }
+ endEditing($0, shouldCreateNewItem: $1)
+ }
+ )
+ .scaleEffect(draggedItemID == itemID ? dragScaleEffect() : 1.0)
+ .shadow(
+ color: draggedItemID == itemID ? .black.opacity(0.3) : .clear,
+ radius: 12, y: 4
+ )
+ .itemDragGesture(
+ isActive: !item.isCompleted && focusedFieldBinding != .item(itemID),
+ itemID: itemID,
+ onDragStart: { width in
+ layoutStorage.draggedRowWidth = width
+ startDrag(itemID: itemID)
+ },
+ onDragChanged: { point in
+ handleIOSDragChanged(itemID: itemID, point: point)
+ },
+ onDragEnded: { commitIOSDrag() }
+ )
+ .background {
+ if draggedItemID == itemID {
+ Color.clear
+ .onGeometryChange(for: CGRect.self) { proxy in
+ proxy.frame(in: .global)
+ } action: { frame in
+ layoutStorage.draggedRowFrame = frame
+ }
+ }
+ }
+ .padding(.bottom, rowGap)
+ .zIndex(draggedItemID == itemID ? 2 : 1)
+ .id(itemID)
+ }
+
+ draftAppendRow
+
+ ForEach(completedItems) { item in
+ let itemID = item.id
+ let isBeingCleared = iState.clearingItemIDs.contains(itemID)
+ ItemRowView(
+ item: item,
+ itemID: itemID,
+ isSelected: fState.selectedItemID == itemID,
+ isSwiping: $iState.isSwiping,
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteItemWithUndo($0) },
+ onSelect: { selectItem($0) }
+ )
+ .opacity(isBeingCleared ? 0 : 1)
+ .offset(y: isBeingCleared ? 40 : 0)
+ .padding(.bottom, rowGap)
+ .id(itemID)
+ }
+ }
+
+ var body: some View {
+ itemScrollView
+ .overlay(alignment: .topLeading) {
+ if showFPSOverlay {
+ FPSOverlay()
+ .padding(.top, -16)
+ .padding(.leading, 8)
+ .allowsHitTesting(false)
+ }
+ }
+ .simultaneousGesture(
+ SpatialTapGesture(coordinateSpace: .global).onEnded { value in
+ guard value.location.y > layoutStorage.contentBottomY else { return }
+ handleBackgroundTap()
+ }
+ )
+ .accessibilityIdentifier("item-list-scrollview")
+ .background {
+ let isEditing = if case .item = focusedFieldBinding { true } else { false }
+ let isShowingSheet = iState.isShowingSettings || iState.isShowingSyncDiagnostics
+ KeyCommandBridge(
+ isActive: !isEditing && !isShowingSheet,
+ onUp: { _ = navigateUp() },
+ onDown: { _ = navigateDown() },
+ onSpace: { _ = toggleSelectedItem() },
+ onReturn: { _ = focusSelectedItem() },
+ onDelete: { _ = deleteSelectedItemWithUndo() }
+ )
+ }
+ .onAppear {
+ fState.focusedField = .scrollView
+ updateMenuCoordinator()
+ }
+ .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
+ .onChange(of: undoManager, initial: true) { _, newValue in
+ managedObjectContext.undoManager = newValue
+ }
+ .toolbar {
+ platformToolbar
+ }
+ .safeAreaInset(edge: .bottom) {
+ syncErrorBanner
+ }
+ .overlay(alignment: .bottom) {
+ if let toast = iState.undoToast {
+ UndoToastView(
+ data: toast,
+ onUndo: { performUndo() },
+ onDismiss: { dismissUndoToast() }
+ )
+ }
+ }
+ .task(id: iState.undoToast?.id) {
+ guard iState.undoToast != nil else { return }
+ try? await Task.sleep(for: .seconds(7))
+ guard !Task.isCancelled else { return }
+ dismissUndoToast()
+ }
+ .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
+ NavigationStack {
+ SyncDiagnosticsView(syncMonitor: syncMonitor)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button("Close") { iState.isShowingSyncDiagnostics = false }
+ }
+ }
+ }
+ }
+ .sheet(
+ isPresented: Binding(
+ get: { iState.isShowingSettings },
+ set: { iState.isShowingSettings = $0 }
+ )
+ ) {
+ SettingsView(syncMonitor: syncMonitor)
+ }
+ }
+
+ private var itemScrollView: some View {
+ ZStack(alignment: .top) {
+ ScrollView {
+ ScrollViewReader { scrollProxy in
+ VStack(alignment: .leading, spacing: vStackSpacing) {
+ navigationHeader
+ .padding(.bottom, 12)
+ pullToCreateIndicatorRow
+ if isPrependDraftVisible {
+ draftPrependRow
+ .padding(.bottom, rowGap)
+ }
+ itemRows
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .onGeometryChange(for: CGFloat.self) {
+ $0.frame(in: .global).maxY
+ } action: {
+ layoutStorage.contentBottomY = $0
+ }
+ .padding(.trailing, 16)
+ .padding(.vertical, 12)
+ .onChange(of: focusedFieldBinding) { oldValue, newValue in
+ fState.focusedField = newValue
+ handleFocusChange(from: oldValue, to: newValue)
+
+ if newValue == nil,
+ !iState.isShowingSettings,
+ !iState.isShowingSyncDiagnostics
+ {
+ if let pending = fState.pendingFocus {
+ focusedFieldBinding = pending
+ fState.focusedField = pending
+ fState.pendingFocus = nil
+ } else {
+ focusedFieldBinding = .scrollView
+ fState.focusedField = .scrollView
+ }
+ }
+
+ if case .item(let id) = (newValue ?? fState.focusedField),
+ draggedItemID == nil,
+ id != draftPrependRowID
+ {
+ withAnimation {
+ scrollProxy.scrollTo(id)
+ }
+ }
+ }
+ .onChange(of: fState.selectedItemID) { _, newID in
+ if let newID, draggedItemID == nil {
+ guard newID != draftPrependRowID else { return }
+ withAnimation {
+ scrollProxy.scrollTo(newID)
+ }
+ }
+ }
+ }
+ }
+ .scrollDisabled(draggedItemID != nil || iState.isSwiping)
+ .scrollBounceBehavior(.always)
+ .contentMargins(.bottom, 20)
+ .background {
+ Color.outerBackground.ignoresSafeArea()
+ }
+ .overlay {
+ if isCompletelyEmpty && draftPlacement == nil {
+ Text("Pull down to create")
+ .font(ItemRowMetrics.hintSUI)
+ .foregroundStyle(.secondary)
+ .padding(.top, 24)
+ .allowsHitTesting(false)
+ }
+ }
+ .overlay(alignment: .bottom) {
+ pullToClearIndicatorRow
+ }
+ .pullGestures(
+ pullToCreate: pullToCreateStateBinding,
+ pullUpOffset: pullUpOffsetStateBinding,
+ isDraftOpen: draftPlacement != nil,
+ hasCompletedItems: !completedItems.isEmpty,
+ pullCreateThreshold: pullCreateThreshold,
+ flickThreshold: flickThreshold,
+ pullClearThreshold: pullClearThreshold,
+ onCreateItemAtTop: { revealPhantomRow() },
+ onClearCompleted: {
+ let ids = Set(completedItems.map(\.id))
+ withAnimation(.easeIn(duration: 0.35)) {
+ iState.clearingItemIDs = ids
+ } completion: {
+ iState.clearingItemIDs = []
+ clearCompletedItemsWithUndo()
+ }
+ }
+ )
+ .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0)
+
+ }
+ }
+}
+
+
diff --git a/ListlessiOS/Views/ItemRowView.swift b/ListlessiOS/Views/ItemRowView.swift
@@ -0,0 +1,239 @@
+import SwiftUI
+
+struct ItemRowView: View {
+ let item: ItemEntity
+ let itemID: UUID
+ let index: Int
+ let totalItems: Int
+ let isSelected: Bool
+ @Binding var isDragging: Bool
+ @Binding var isSwiping: Bool
+ let onToggle: (ItemEntity) -> Void
+ let onTitleChange: (ItemEntity, String) -> Void
+ let onDelete: (ItemEntity) -> Void
+ let onSelect: (UUID) -> Void
+ let isLastActiveItem: Bool
+ let onStartEdit: (UUID) -> Void
+ let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
+ @FocusState.Binding var focusedField: FocusField?
+
+ @AppStorage("colorTheme") private var colorThemeRaw = 0
+ private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
+ @State private var swipeOffset: CGFloat = 0
+ @State private var swipeDirection: ItemRowSwipeGesture.SwipeDirection = .none
+ @State private var isSwipeTriggered: Bool = false
+ @State private var editingTitle: String = ""
+ @State private var isCurrentlyEditing: Bool = false
+ @State private var tapPoint: CGPoint? = nil
+ @State private var cachedAccentColor: Color = .clear
+
+ init(
+ item: ItemEntity,
+ itemID: UUID,
+ index: Int = 0,
+ totalItems: Int = 1,
+ isSelected: Bool,
+ isDragging: Binding<Bool> = .constant(false),
+ isSwiping: Binding<Bool> = .constant(false),
+ isLastActiveItem: Bool = false,
+ focusedField: FocusState<FocusField?>.Binding,
+ onToggle: @escaping (ItemEntity) -> Void,
+ onTitleChange: @escaping (ItemEntity, String) -> Void,
+ onDelete: @escaping (ItemEntity) -> Void,
+ onSelect: @escaping (UUID) -> Void,
+ onStartEdit: @escaping (UUID) -> Void = { _ in },
+ onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }
+ ) {
+ self.item = item
+ self.itemID = itemID
+ self.index = index
+ self.totalItems = totalItems
+ self.isSelected = isSelected
+ _isDragging = isDragging
+ _isSwiping = isSwiping
+ self.isLastActiveItem = isLastActiveItem
+ self.onToggle = onToggle
+ self.onTitleChange = onTitleChange
+ self.onDelete = onDelete
+ self.onSelect = onSelect
+ self.onStartEdit = onStartEdit
+ self.onEndEdit = onEndEdit
+ _focusedField = focusedField
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
+ Button {
+ onToggle(item)
+ } label: {
+ // When a right-swipe is past the threshold, preview the toggled state
+ let previewCompleted = isSwipeTriggered && swipeDirection == .right
+ ? !item.isCompleted
+ : item.isCompleted
+ Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
+ .contentTransition(.identity)
+ .frame(width: 22, height: 22)
+ .foregroundStyle(Color.secondary)
+ .font(.system(size: 17))
+ }
+ .buttonStyle(.borderless)
+ .accessibilityIdentifier("item-checkbox")
+ .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
+
+ if !item.isCompleted && (isSelected || isEditing) {
+ TappableTextField(
+ text: $editingTitle,
+ isCompleted: item.isCompleted,
+ isDragging: isDragging,
+ onEditingChanged: { editing, shouldCreateNewItem in
+ DispatchQueue.main.async {
+ isCurrentlyEditing = editing
+ if editing { onStartEdit(itemID) }
+ else {
+ tapPoint = nil
+ onEndEdit(itemID, shouldCreateNewItem)
+ }
+ }
+ },
+ returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done,
+ onContentChange: { newTitle in
+ guard !item.isCompleted else { return }
+ onTitleChange(item, newTitle)
+ },
+ uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)",
+ initialCursorPoint: tapPoint
+ )
+ .focused($focusedField, equals: .item(itemID))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else if !item.isCompleted {
+ itemProxy
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .gesture(SpatialTapGesture().onEnded { value in
+ tapPoint = value.location
+ onSelect(itemID)
+ focusedField = .item(itemID)
+ })
+ } else {
+ itemProxy
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
+ .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
+ .padding(
+ .leading,
+ item.isCompleted ? ItemRowMetrics.completedLeadingPadding : ItemRowMetrics.activeLeadingPadding
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ // .onTapGesture (not .simultaneousGesture) lets the child Button suppress this
+ // gesture for its own hit area, so circle button taps don't also fire here.
+ // If tapping a completed row while another row is being edited, preserve
+ // the current focus/selection.
+ if item.isCompleted,
+ let field = focusedField,
+ case .item(let id) = field,
+ id != itemID
+ {
+ return
+ }
+ if item.isCompleted {
+ withAnimation { onToggle(item) }
+ } else {
+ tapPoint = nil
+ onSelect(itemID)
+ focusedField = .item(itemID)
+ }
+ }
+ .background(cardBackground)
+ .overlay(alignment: .leading) {
+ if !item.isCompleted {
+ Rectangle()
+ .fill(cachedAccentColor)
+ .frame(width: ItemRowMetrics.accentBarWidth)
+ }
+ }
+ .onAppear {
+ editingTitle = item.title
+ cachedAccentColor = computeAccentColor()
+ }
+ .onChange(of: item.title) { _, newValue in
+ if !isCurrentlyEditing {
+ editingTitle = newValue
+ }
+ }
+ .onChange(of: index) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .onChange(of: totalItems) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .onChange(of: colorThemeRaw) { _, _ in
+ cachedAccentColor = computeAccentColor()
+ }
+ .itemSwipeGesture(
+ isDragging: $isDragging,
+ isEditing: focusedField == .item(itemID),
+ isSwiping: $isSwiping,
+ swipeOffset: $swipeOffset,
+ swipeDirection: $swipeDirection,
+ isTriggered: $isSwipeTriggered,
+ completeColor: cachedAccentColor,
+ onComplete: { onToggle(item) },
+ onDelete: { onDelete(item) }
+ )
+ .onChange(of: isDragging) { _, newValue in
+ if newValue {
+ swipeOffset = 0
+ swipeDirection = .none
+ isSwipeTriggered = false
+ }
+ }
+ .clipShape(ItemCardModifier.shape)
+ .overlay(
+ isSelected && !item.isCompleted
+ ? ItemCardModifier.shape
+ .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
+ : nil
+ )
+ }
+
+ private var isEditing: Bool {
+ focusedField == .item(itemID)
+ }
+
+ @ViewBuilder
+ private var itemProxy: some View {
+ if item.isCompleted {
+ Text(editingTitle)
+ .font(ItemRowMetrics.bodySUI)
+ .foregroundStyle(.secondary)
+ .strikethrough(true, color: .secondary)
+ .accessibilityIdentifier("item-text-\(itemID.uuidString)")
+ } else {
+ Text(editingTitle)
+ .font(ItemRowMetrics.bodySUI)
+ .foregroundStyle(.primary)
+ .accessibilityIdentifier("item-text-\(itemID.uuidString)")
+ }
+ }
+
+ @MainActor
+ private func computeAccentColor() -> Color {
+ guard !item.isCompleted else { return .clear }
+ return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
+ }
+
+ @ViewBuilder
+ private var cardBackground: some View {
+ if item.isCompleted {
+ isSelected ? Color.completedSelected : Color.clear
+ } else if isSelected {
+ Color.itemCard.overlay(cachedAccentColor.opacity(0.15))
+ } else {
+ Color.itemCard
+ }
+ }
+}
diff --git a/ListlessiOS/Views/PullToClear.swift b/ListlessiOS/Views/PullToClear.swift
@@ -1,6 +1,6 @@
import SwiftUI
-/// Pull distance at which the indicator signals readiness and completed task clearing triggers.
+/// Pull distance at which the indicator signals readiness and completed item clearing triggers.
let pullClearThreshold: CGFloat = 50
struct PullToClearIndicator: View {
@@ -31,7 +31,7 @@ struct PullToClearIndicator: View {
.offset(y: isReady ? -textSlideDistance : 0)
}
.foregroundStyle(.secondary)
- .font(TaskRowMetrics.hintSUI)
+ .font(ItemRowMetrics.hintSUI)
.frame(height: textSlideDistance, alignment: .topLeading)
.clipped()
.animation(.easeInOut(duration: 0.15), value: isReady)
diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift
@@ -12,7 +12,7 @@ struct PullToCreateIndicator: View {
private let textSlideDistance: CGFloat = 22
var body: some View {
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
+ HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
Image(systemName: "circle")
.frame(width: 22, height: 22)
.foregroundStyle(Color.secondary)
@@ -24,29 +24,29 @@ struct PullToCreateIndicator: View {
.offset(y: isReady ? textSlideDistance : 0)
}
.foregroundStyle(.secondary)
- .font(TaskRowMetrics.bodySUI)
+ .font(ItemRowMetrics.bodySUI)
.frame(height: textSlideDistance, alignment: .topLeading)
.clipped()
.animation(.easeInOut(duration: 0.18), value: isReady)
Spacer()
}
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
- .padding(.leading, TaskRowMetrics.activeLeadingPadding)
+ .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
+ .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
+ .padding(.leading, ItemRowMetrics.activeLeadingPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
- .background(Color.taskCard)
+ .background(Color.itemCard)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
+ bottomTrailingRadius: ItemRowMetrics.trailingCornerRadius,
+ topTrailingRadius: ItemRowMetrics.trailingCornerRadius
)
)
.overlay(alignment: .leading) {
Rectangle()
- .fill(taskColor(forIndex: 0, total: 1, theme: colorTheme))
- .frame(width: TaskRowMetrics.accentBarWidth)
+ .fill(itemColor(forIndex: 0, total: 1, theme: colorTheme))
+ .frame(width: ItemRowMetrics.accentBarWidth)
}
.frame(height: Self.indicatorHeight, alignment: .top)
.allowsHitTesting(false)
diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift
@@ -32,9 +32,9 @@ struct SettingsView: View {
Spacer()
LinearGradient(
colors: [
- taskColor(forIndex: 0, total: 5, theme: theme),
- taskColor(forIndex: 2, total: 5, theme: theme),
- taskColor(forIndex: 4, total: 5, theme: theme),
+ itemColor(forIndex: 0, total: 5, theme: theme),
+ itemColor(forIndex: 2, total: 5, theme: theme),
+ itemColor(forIndex: 4, total: 5, theme: theme),
],
startPoint: .leading,
endPoint: .trailing
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -1,588 +0,0 @@
-import SwiftUI
-import UIKit
-
-struct TaskListView: View, TaskListViewProtocol {
- class LayoutStorage {
- var draggedRowWidth: CGFloat = 0
- var draggedRowFrame: CGRect = .zero
- var contentBottomY: CGFloat = 0
- }
-
- struct InteractionStateData {
- var dragState: DragState = .idle
- var draftCount: Int = 0
- var isShowingSyncDiagnostics = false
- var isShowingSettings = false
- var clearingTaskIDs: Set<UUID> = []
- var undoToast: UndoToastData? = nil
- var isSwiping: Bool = false
- var draftPlacement: DraftTaskPlacement?
- var draftTitle: String = ""
- var fetchWorkaround: Int = 0
- }
-
- struct PullStateData {
- var pullToCreate = PullToCreateState()
- var pullUpOffset: CGFloat = 0
-
- var headerHeight: CGFloat = 60
- }
-
- @AppStorage("headingText") var headingText = "Items"
- @AppStorage("colorTheme") private var colorThemeRaw = 0
- @AppStorage("hapticsEnabled") private var hapticsEnabled = true
- @AppStorage("showFPSOverlay") private var showFPSOverlay = false
- private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
- @Environment(\.undoManager) var undoManager
- @Environment(\.managedObjectContext) var managedObjectContext
-
- let store: TaskStore
- @ObservedObject var syncMonitor: CloudKitSyncMonitor
- @FetchRequest(
- sortDescriptors: [],
- animation: .default
- )
- var tasks: FetchedResults<TaskItem>
- @FocusState private var focusedFieldBinding: FocusField?
- @State var fState = FocusStateData()
- @State var iState = InteractionStateData()
- @State var pState = PullStateData()
- @State var isDragging = false
- @State var layoutStorage = LayoutStorage()
-
- var focusedField: FocusField? {
- get { fState.focusedField }
- nonmutating set {
- fState.focusedField = newValue
- focusedFieldBinding = newValue
- }
- }
-
- var dragState: DragState {
- get { iState.dragState }
- nonmutating set { iState.dragState = newValue }
- }
-
- var draftPlacement: DraftTaskPlacement? {
- get { iState.draftPlacement }
- nonmutating set {
- if newValue != nil, iState.draftPlacement == nil {
- iState.draftCount += 1
- }
- iState.draftPlacement = newValue
- }
- }
-
- var draftTitle: String {
- get { iState.draftTitle }
- nonmutating set { iState.draftTitle = newValue }
- }
-
- private var isPrependDraftVisible: Bool {
- draftPlacement == .prepend
- }
-
- private var isAppendDraftVisible: Bool {
- draftPlacement == .append
- }
-
- var draftTitleBinding: Binding<String> {
- Binding(
- get: { iState.draftTitle },
- set: { iState.draftTitle = $0 }
- )
- }
-
- private var isDraggingStateBinding: Binding<Bool> {
- $isDragging
- }
-
- private var pullToCreateStateBinding: Binding<PullToCreateState> {
- Binding(
- get: { pState.pullToCreate },
- set: { pState.pullToCreate = $0 }
- )
- }
-
- private var pullUpOffsetStateBinding: Binding<CGFloat> {
- Binding(
- get: { pState.pullUpOffset },
- set: { pState.pullUpOffset = $0 }
- )
- }
-
- private var isShowingSyncDiagnosticsStateBinding: Binding<Bool> {
- Binding(
- get: { iState.isShowingSyncDiagnostics },
- set: { iState.isShowingSyncDiagnostics = $0 }
- )
- }
-
- private var selectedIndex: Int? {
- guard let currentID = fState.selectedTaskID else { return nil }
- return activeTasks.firstIndex(where: { $0.id == currentID })
- }
-
- private var canMoveSelectionUp: Bool {
- guard focusedField == .scrollView else { return false }
- guard let index = selectedIndex else { return false }
- return index > 0
- }
-
- private var canMoveSelectionDown: Bool {
- guard focusedField == .scrollView else { return false }
- guard let index = selectedIndex else { return false }
- return index < activeTasks.count - 1
- }
-
- private struct MenuState: Equatable {
- let selectedTaskID: UUID?
- let isScrollViewFocused: Bool
- let activeTaskCount: Int
- let completedTaskCount: Int
- let selectedIndex: Int?
- }
-
- private var menuCoordinatorTrigger: MenuState {
- MenuState(
- selectedTaskID: fState.selectedTaskID,
- isScrollViewFocused: focusedField == .scrollView,
- activeTaskCount: activeTasks.count,
- completedTaskCount: completedTasks.count,
- selectedIndex: selectedIndex
- )
- }
-
- func updateMenuCoordinator() {
- let coord = IOSMenuCoordinator.shared
- coord.newTask = { createNewTask() }
- coord.deleteTask = { _ = deleteSelectedTaskWithUndo() }
- coord.moveUp = { moveSelectedTaskUp() }
- coord.moveDown = { moveSelectedTaskDown() }
- coord.markCompleted = { markSelectedTaskCompleted() }
- let inNavMode = focusedField == .scrollView
- coord.canDelete = fState.selectedTaskID != nil && inNavMode
- coord.canMoveUp = canMoveSelectionUp
- coord.canMoveDown = canMoveSelectionDown
- coord.canMarkCompleted = fState.selectedTaskID != nil && inNavMode
- coord.markCompletedTitle = completedTasks.contains(where: { $0.id == fState.selectedTaskID })
- ? "Mark as Incomplete" : "Mark as Complete"
- }
-
- var vStackSpacing: CGFloat { 0 }
- var rowGap: CGFloat { 12 }
- var pullCreateThreshold: CGFloat { 70 }
- var flickThreshold: CGFloat { 500 }
- var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
-
- init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) {
- self.store = store
- self.syncMonitor = syncMonitor
- }
-
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool) {
- let clear: () -> Void = {
- if draftPlacement == placement {
- draftPlacement = nil
- }
- draftTitle = ""
- if fState.selectedTaskID == draftID(for: placement) {
- fState.selectedTaskID = nil
- }
-
- guard placement == .prepend else { return }
-
- var state = pState.pullToCreate
- state.isInsertionPending = false
- state.indicatorOffset = 0
- pState.pullToCreate = state
- }
-
- if placement == .prepend, !hasTitle {
- withAnimation(.spring(response: 0.24, dampingFraction: 1.0)) {
- clear()
- }
- } else if placement == .prepend {
- var transaction = Transaction(animation: nil)
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- clear()
- }
- } else {
- clear()
- }
-
- if placement == .prepend || !hasTitle {
- focusedField = nil
- }
- }
-
- func didStartDrag() {
- isDragging = true
- if hapticsEnabled {
- let generator = UIImpactFeedbackGenerator(style: .light)
- generator.impactOccurred()
- }
- }
-
- func showSyncDiagnostics() {
- iState.isShowingSyncDiagnostics = true
- }
-
- func showSettings() {
- iState.isShowingSettings = true
- }
-
- private func dragScaleEffect() -> CGFloat {
- let liftPoints: CGFloat = 20
- let width = layoutStorage.draggedRowWidth
- guard width > 0 else { return 1.05 }
- return (width + liftPoints) / width
- }
-
- /// Combined indicator and phantom entry row sharing the same VStack slot.
- /// The phantom's UITextView is created while the indicator is visible
- /// (during the pull), so it's ready when the user releases.
- @ViewBuilder var pullToCreateIndicatorRow: some View {
- let pullOffset = pState.pullToCreate.pullOffset
- let indicatorHeight = PullToCreateIndicator.indicatorHeight
- let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset(
- threshold: pullCreateThreshold
- )
- let frameHeight: CGFloat = isPrependDraftVisible
- ? 0
- : min(pullOffset, indicatorHeight + rowGap)
- let opacity: Double = isPrependDraftVisible || pullOffset <= 0 ? 0 : 1
- PullToCreateIndicator(
- pullOffset: max(0, indicatorDisplayOffset),
- threshold: pullCreateThreshold
- )
- .frame(
- height: frameHeight,
- alignment: .top
- )
- .opacity(opacity)
- }
-
- /// The draft row content styled to match a task row. Controlled by the
- /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility.
- @ViewBuilder private var draftPrependRow: some View {
- DraftRowView(
- accentColor: taskColor(
- forIndex: 0, total: max(1, displayActiveTasks.count + 1), theme: colorTheme
- ),
- isSelected: fState.selectedTaskID == draftPrependRowID,
- draftID: draftPrependRowID,
- title: draftTitleBinding,
- onEditingChanged: { editing, _ in
- DispatchQueue.main.async {
- if editing {
- beginDraftTaskEditing(.prepend)
- } else {
- commitDraftTask()
- }
- }
- },
- returnKeyType: .done,
- accessibilityIdentifier: "draft-row-prepend",
- focusedField: $focusedFieldBinding
- )
- }
-
- @ViewBuilder private var draftAppendRow: some View {
- if isAppendDraftVisible {
- DraftRowView(
- accentColor: taskColor(
- forIndex: displayActiveTasks.count,
- total: max(1, displayActiveTasks.count + 1),
- theme: colorTheme
- ),
- isSelected: fState.selectedTaskID == draftAppendRowID,
- draftID: draftAppendRowID,
- title: draftTitleBinding,
- onEditingChanged: { editing, shouldCreateNewTask in
- DispatchQueue.main.async {
- if editing {
- beginDraftTaskEditing(.append)
- } else {
- commitDraftTask(
- shouldCreateNewTask: shouldCreateNewTask
- )
- }
- }
- },
- returnKeyType: draftTitle.trimmingCharacters(
- in: .whitespacesAndNewlines
- ).isEmpty ? .done : .next,
- accessibilityIdentifier: "draft-row-append",
- focusedField: $focusedFieldBinding
- )
- .padding(.bottom, rowGap)
- .id(draftAppendRowID)
- }
- }
-
- @ViewBuilder private var taskRows: some View {
- let _ = iState.fetchWorkaround
- let draftOffset = isPrependDraftVisible ? 1 : 0
- let draftTotal = draftPlacement != nil ? 1 : 0
- ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- index: index + draftOffset,
- totalTasks: displayActiveTasks.count + draftTotal,
- isSelected: fState.selectedTaskID == taskID,
- isDragging: isDraggingStateBinding,
- isSwiping: $iState.isSwiping,
- isLastActiveTask: index == displayActiveTasks.count - 1,
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) },
- onStartEdit: { startEditing($0) },
- onEndEdit: {
- if fState.selectedTaskID == $0 {
- fState.selectedTaskID = nil
- }
- endEditing($0, shouldCreateNewTask: $1)
- }
- )
- .scaleEffect(draggedTaskID == taskID ? dragScaleEffect() : 1.0)
- .shadow(
- color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
- radius: 12, y: 4
- )
- .taskDragGesture(
- isActive: !task.isCompleted && focusedFieldBinding != .task(taskID),
- taskID: taskID,
- onDragStart: { width in
- layoutStorage.draggedRowWidth = width
- startDrag(taskID: taskID)
- },
- onDragChanged: { point in
- handleIOSDragChanged(taskID: taskID, point: point)
- },
- onDragEnded: { commitIOSDrag() }
- )
- .background {
- if draggedTaskID == taskID {
- Color.clear
- .onGeometryChange(for: CGRect.self) { proxy in
- proxy.frame(in: .global)
- } action: { frame in
- layoutStorage.draggedRowFrame = frame
- }
- }
- }
- .padding(.bottom, rowGap)
- .zIndex(draggedTaskID == taskID ? 2 : 1)
- .id(taskID)
- }
-
- draftAppendRow
-
- ForEach(completedTasks) { task in
- let taskID = task.id
- let isBeingCleared = iState.clearingTaskIDs.contains(taskID)
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: fState.selectedTaskID == taskID,
- isSwiping: $iState.isSwiping,
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) }
- )
- .opacity(isBeingCleared ? 0 : 1)
- .offset(y: isBeingCleared ? 40 : 0)
- .padding(.bottom, rowGap)
- .id(taskID)
- }
- }
-
- var body: some View {
- taskScrollView
- .overlay(alignment: .topLeading) {
- if showFPSOverlay {
- FPSOverlay()
- .padding(.top, -16)
- .padding(.leading, 8)
- .allowsHitTesting(false)
- }
- }
- .simultaneousGesture(
- SpatialTapGesture(coordinateSpace: .global).onEnded { value in
- guard value.location.y > layoutStorage.contentBottomY else { return }
- handleBackgroundTap()
- }
- )
- .accessibilityIdentifier("task-list-scrollview")
- .background {
- let isEditing = if case .task = focusedFieldBinding { true } else { false }
- let isShowingSheet = iState.isShowingSettings || iState.isShowingSyncDiagnostics
- KeyCommandBridge(
- isActive: !isEditing && !isShowingSheet,
- onUp: { _ = navigateUp() },
- onDown: { _ = navigateDown() },
- onSpace: { _ = toggleSelectedTask() },
- onReturn: { _ = focusSelectedTask() },
- onDelete: { _ = deleteSelectedTaskWithUndo() }
- )
- }
- .onAppear {
- fState.focusedField = .scrollView
- updateMenuCoordinator()
- }
- .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
- .onChange(of: undoManager, initial: true) { _, newValue in
- managedObjectContext.undoManager = newValue
- }
- .toolbar {
- platformToolbar
- }
- .safeAreaInset(edge: .bottom) {
- syncErrorBanner
- }
- .overlay(alignment: .bottom) {
- if let toast = iState.undoToast {
- UndoToastView(
- data: toast,
- onUndo: { performUndo() },
- onDismiss: { dismissUndoToast() }
- )
- }
- }
- .task(id: iState.undoToast?.id) {
- guard iState.undoToast != nil else { return }
- try? await Task.sleep(for: .seconds(7))
- guard !Task.isCancelled else { return }
- dismissUndoToast()
- }
- .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
- NavigationStack {
- SyncDiagnosticsView(syncMonitor: syncMonitor)
- .toolbar {
- ToolbarItem(placement: .topBarLeading) {
- Button("Close") { iState.isShowingSyncDiagnostics = false }
- }
- }
- }
- }
- .sheet(
- isPresented: Binding(
- get: { iState.isShowingSettings },
- set: { iState.isShowingSettings = $0 }
- )
- ) {
- SettingsView(syncMonitor: syncMonitor)
- }
- }
-
- private var taskScrollView: some View {
- ZStack(alignment: .top) {
- ScrollView {
- ScrollViewReader { scrollProxy in
- VStack(alignment: .leading, spacing: vStackSpacing) {
- navigationHeader
- .padding(.bottom, 12)
- pullToCreateIndicatorRow
- if isPrependDraftVisible {
- draftPrependRow
- .padding(.bottom, rowGap)
- }
- taskRows
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .onGeometryChange(for: CGFloat.self) {
- $0.frame(in: .global).maxY
- } action: {
- layoutStorage.contentBottomY = $0
- }
- .padding(.trailing, 16)
- .padding(.vertical, 12)
- .onChange(of: focusedFieldBinding) { oldValue, newValue in
- fState.focusedField = newValue
- handleFocusChange(from: oldValue, to: newValue)
-
- if newValue == nil,
- !iState.isShowingSettings,
- !iState.isShowingSyncDiagnostics
- {
- if let pending = fState.pendingFocus {
- focusedFieldBinding = pending
- fState.focusedField = pending
- fState.pendingFocus = nil
- } else {
- focusedFieldBinding = .scrollView
- fState.focusedField = .scrollView
- }
- }
-
- if case .task(let id) = (newValue ?? fState.focusedField),
- draggedTaskID == nil,
- id != draftPrependRowID
- {
- withAnimation {
- scrollProxy.scrollTo(id)
- }
- }
- }
- .onChange(of: fState.selectedTaskID) { _, newID in
- if let newID, draggedTaskID == nil {
- guard newID != draftPrependRowID else { return }
- withAnimation {
- scrollProxy.scrollTo(newID)
- }
- }
- }
- }
- }
- .scrollDisabled(draggedTaskID != nil || iState.isSwiping)
- .scrollBounceBehavior(.always)
- .contentMargins(.bottom, 20)
- .background {
- Color.outerBackground.ignoresSafeArea()
- }
- .overlay {
- if isCompletelyEmpty && draftPlacement == nil {
- Text("Pull down to create")
- .font(TaskRowMetrics.hintSUI)
- .foregroundStyle(.secondary)
- .padding(.top, 24)
- .allowsHitTesting(false)
- }
- }
- .overlay(alignment: .bottom) {
- pullToClearIndicatorRow
- }
- .pullGestures(
- pullToCreate: pullToCreateStateBinding,
- pullUpOffset: pullUpOffsetStateBinding,
- isDraftOpen: draftPlacement != nil,
- hasCompletedTasks: !completedTasks.isEmpty,
- pullCreateThreshold: pullCreateThreshold,
- flickThreshold: flickThreshold,
- pullClearThreshold: pullClearThreshold,
- onCreateTaskAtTop: { revealPhantomRow() },
- onClearCompleted: {
- let ids = Set(completedTasks.map(\.id))
- withAnimation(.easeIn(duration: 0.35)) {
- iState.clearingTaskIDs = ids
- } completion: {
- iState.clearingTaskIDs = []
- clearCompletedTasksWithUndo()
- }
- }
- )
- .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0)
-
- }
- }
-}
-
-
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -1,239 +0,0 @@
-import SwiftUI
-
-struct TaskRowView: View {
- let task: TaskItem
- let taskID: UUID
- let index: Int
- let totalTasks: Int
- let isSelected: Bool
- @Binding var isDragging: Bool
- @Binding var isSwiping: Bool
- let onToggle: (TaskItem) -> Void
- let onTitleChange: (TaskItem, String) -> Void
- let onDelete: (TaskItem) -> Void
- let onSelect: (UUID) -> Void
- let isLastActiveTask: Bool
- let onStartEdit: (UUID) -> Void
- let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void
- @FocusState.Binding var focusedField: FocusField?
-
- @AppStorage("colorTheme") private var colorThemeRaw = 0
- private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
- @State private var swipeOffset: CGFloat = 0
- @State private var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none
- @State private var isSwipeTriggered: Bool = false
- @State private var editingTitle: String = ""
- @State private var isCurrentlyEditing: Bool = false
- @State private var tapPoint: CGPoint? = nil
- @State private var cachedAccentColor: Color = .clear
-
- init(
- task: TaskItem,
- taskID: UUID,
- index: Int = 0,
- totalTasks: Int = 1,
- isSelected: Bool,
- isDragging: Binding<Bool> = .constant(false),
- isSwiping: Binding<Bool> = .constant(false),
- isLastActiveTask: Bool = false,
- focusedField: FocusState<FocusField?>.Binding,
- onToggle: @escaping (TaskItem) -> Void,
- onTitleChange: @escaping (TaskItem, String) -> Void,
- onDelete: @escaping (TaskItem) -> Void,
- onSelect: @escaping (UUID) -> Void,
- onStartEdit: @escaping (UUID) -> Void = { _ in },
- onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in }
- ) {
- self.task = task
- self.taskID = taskID
- self.index = index
- self.totalTasks = totalTasks
- self.isSelected = isSelected
- _isDragging = isDragging
- _isSwiping = isSwiping
- self.isLastActiveTask = isLastActiveTask
- self.onToggle = onToggle
- self.onTitleChange = onTitleChange
- self.onDelete = onDelete
- self.onSelect = onSelect
- self.onStartEdit = onStartEdit
- self.onEndEdit = onEndEdit
- _focusedField = focusedField
- }
-
- var body: some View {
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
- Button {
- onToggle(task)
- } label: {
- // When a right-swipe is past the threshold, preview the toggled state
- let previewCompleted = isSwipeTriggered && swipeDirection == .right
- ? !task.isCompleted
- : task.isCompleted
- Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
- .contentTransition(.identity)
- .frame(width: 22, height: 22)
- .foregroundStyle(Color.secondary)
- .font(.system(size: 17))
- }
- .buttonStyle(.borderless)
- .accessibilityIdentifier("task-checkbox")
- .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
-
- if !task.isCompleted && (isSelected || isEditing) {
- TappableTextField(
- text: $editingTitle,
- isCompleted: task.isCompleted,
- isDragging: isDragging,
- onEditingChanged: { editing, shouldCreateNewTask in
- DispatchQueue.main.async {
- isCurrentlyEditing = editing
- if editing { onStartEdit(taskID) }
- else {
- tapPoint = nil
- onEndEdit(taskID, shouldCreateNewTask)
- }
- }
- },
- returnKeyType: isLastActiveTask && !editingTitle.isEmpty ? .next : .done,
- onContentChange: { newTitle in
- guard !task.isCompleted else { return }
- onTitleChange(task, newTitle)
- },
- uiAccessibilityIdentifier: "task-text-\(taskID.uuidString)",
- initialCursorPoint: tapPoint
- )
- .focused($focusedField, equals: .task(taskID))
- .frame(maxWidth: .infinity, alignment: .leading)
- } else if !task.isCompleted {
- taskProxy
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .gesture(SpatialTapGesture().onEnded { value in
- tapPoint = value.location
- onSelect(taskID)
- focusedField = .task(taskID)
- })
- } else {
- taskProxy
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
- .padding(
- .leading,
- task.isCompleted ? TaskRowMetrics.completedLeadingPadding : TaskRowMetrics.activeLeadingPadding
- )
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .onTapGesture {
- // .onTapGesture (not .simultaneousGesture) lets the child Button suppress this
- // gesture for its own hit area, so circle button taps don't also fire here.
- // If tapping a completed row while another row is being edited, preserve
- // the current focus/selection.
- if task.isCompleted,
- let field = focusedField,
- case .task(let id) = field,
- id != taskID
- {
- return
- }
- if task.isCompleted {
- withAnimation { onToggle(task) }
- } else {
- tapPoint = nil
- onSelect(taskID)
- focusedField = .task(taskID)
- }
- }
- .background(cardBackground)
- .overlay(alignment: .leading) {
- if !task.isCompleted {
- Rectangle()
- .fill(cachedAccentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
- }
- }
- .onAppear {
- editingTitle = task.title
- cachedAccentColor = computeAccentColor()
- }
- .onChange(of: task.title) { _, newValue in
- if !isCurrentlyEditing {
- editingTitle = newValue
- }
- }
- .onChange(of: index) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .onChange(of: totalTasks) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .onChange(of: colorThemeRaw) { _, _ in
- cachedAccentColor = computeAccentColor()
- }
- .taskSwipeGesture(
- isDragging: $isDragging,
- isEditing: focusedField == .task(taskID),
- isSwiping: $isSwiping,
- swipeOffset: $swipeOffset,
- swipeDirection: $swipeDirection,
- isTriggered: $isSwipeTriggered,
- completeColor: cachedAccentColor,
- onComplete: { onToggle(task) },
- onDelete: { onDelete(task) }
- )
- .onChange(of: isDragging) { _, newValue in
- if newValue {
- swipeOffset = 0
- swipeDirection = .none
- isSwipeTriggered = false
- }
- }
- .clipShape(TaskCardModifier.shape)
- .overlay(
- isSelected && !task.isCompleted
- ? TaskCardModifier.shape
- .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
- : nil
- )
- }
-
- private var isEditing: Bool {
- focusedField == .task(taskID)
- }
-
- @ViewBuilder
- private var taskProxy: some View {
- if task.isCompleted {
- Text(editingTitle)
- .font(TaskRowMetrics.bodySUI)
- .foregroundStyle(.secondary)
- .strikethrough(true, color: .secondary)
- .accessibilityIdentifier("task-text-\(taskID.uuidString)")
- } else {
- Text(editingTitle)
- .font(TaskRowMetrics.bodySUI)
- .foregroundStyle(.primary)
- .accessibilityIdentifier("task-text-\(taskID.uuidString)")
- }
- }
-
- @MainActor
- private func computeAccentColor() -> Color {
- guard !task.isCompleted else { return .clear }
- return cachedTaskColor(forIndex: index, total: totalTasks, theme: colorTheme)
- }
-
- @ViewBuilder
- private var cardBackground: some View {
- if task.isCompleted {
- isSelected ? Color.completedSelected : Color.clear
- } else if isSelected {
- Color.taskCard.overlay(cachedAccentColor.opacity(0.15))
- } else {
- Color.taskCard
- }
- }
-}
diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift
@@ -7,31 +7,31 @@ import Testing
@testable import Listless_iOS
#endif
-/// Creates a fresh TaskStore with in-memory persistence for isolated testing.
+/// Creates a fresh ItemStore with in-memory persistence for isolated testing.
@MainActor
-func makeTestStore() -> TaskStore {
+func makeTestStore() -> ItemStore {
let controller = PersistenceController(inMemory: true)
- return TaskStore(persistenceController: controller)
+ return ItemStore(persistenceController: controller)
}
-/// Creates a TaskStore pre-populated with test tasks.
+/// Creates a ItemStore pre-populated with test items.
/// - Parameters:
-/// - count: Number of tasks to create (default: 3)
-/// - titles: Optional array of titles; if nil, generates "Task 1", "Task 2", etc.
-/// - Returns: Tuple of (store, array of created task IDs)
+/// - count: Number of items to create (default: 3)
+/// - titles: Optional array of titles; if nil, generates "Item 1", "Item 2", etc.
+/// - Returns: Tuple of (store, array of created item IDs)
@MainActor
-func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) throws -> (TaskStore, [UUID]) {
+func makeTestStoreWithItems(count: Int = 3, titles: [String]? = nil) throws -> (ItemStore, [UUID]) {
let store = makeTestStore()
- var taskIDs: [UUID] = []
+ var itemIDs: [UUID] = []
for i in 0..<count {
let title = titles?[safe: i] ?? "Task \(i + 1)"
- let task = try store.createTask(title: title)
+ let item = try store.createItem(title: title)
try store.save()
- taskIDs.append(task.id)
+ itemIDs.append(item.id)
}
- return (store, taskIDs)
+ return (store, itemIDs)
}
/// Safe array subscript that returns nil instead of crashing on out-of-bounds access.
diff --git a/Tests/UI/ListlessMacUITests.swift b/Tests/UI/ListlessMacUITests.swift
@@ -27,16 +27,16 @@ final class ListlessMacUITests: XCTestCase {
app.textFields["draft-row-append"]
}
- /// Returns the text field for a committed task with the given title.
- func taskText(_ title: String) -> XCUIElement {
+ /// Returns the text field for a committed item with the given title.
+ func itemText(_ title: String) -> XCUIElement {
app.textFields.matching(
- NSPredicate(format: "identifier BEGINSWITH 'task-text-' AND value == %@", title)
+ NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND value == %@", title)
).firstMatch
}
- /// Creates a task by typing into the draft field and pressing Return.
+ /// Creates a item by typing into the draft field and pressing Return.
/// If no draft field exists yet, presses Cmd+N to create one.
- func createTask(_ title: String) {
+ func createItem(_ title: String) {
let textField = draftTextField
if !textField.exists {
app.typeKey("n", modifierFlags: .command)
@@ -51,26 +51,26 @@ final class ListlessMacUITests: XCTestCase {
}
/// Returns the Nth checkbox button (0-indexed).
- func taskCheckbox(at index: Int) -> XCUIElement {
- app.buttons.matching(identifier: "task-checkbox").element(boundBy: index)
+ func itemCheckbox(at index: Int) -> XCUIElement {
+ app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
}
- /// Enters navigation mode by pressing Escape, then navigates to the task at
+ /// Enters navigation mode by pressing Escape, then navigates to the item at
/// the given position (0-indexed from the top) using arrow keys.
- func navigateToTask(at index: Int) {
+ func navigateToItem(at index: Int) {
app.typeKey(.escape, modifierFlags: [])
for _ in 0...index {
app.typeKey(.downArrow, modifierFlags: [])
}
}
- /// Performs a Command+Click on the row containing the given task title.
+ /// Performs a Command+Click on the row containing the given item title.
/// Uses CGEvent mouse events with `.maskCommand` so the app sees the
/// modifier via `NSApp.currentEvent?.modifierFlags`. Clicks in the
/// row's left padding area (before the checkbox) so the tap gesture
/// fires rather than the text field or checkbox.
func cmdClickRow(withText title: String) {
- let textField = taskText(title)
+ let textField = itemText(title)
XCTAssertTrue(textField.waitForExistence(timeout: 2))
// Offset to the left of the text field, into the row's 16pt left padding.
let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
@@ -105,13 +105,13 @@ final class ListlessMacUITests: XCTestCase {
)
}
- func testEmptyStateDisappearsAfterCreatingTask() {
- createTask("First item")
+ func testEmptyStateDisappearsAfterCreatingItem() {
+ createItem("First item")
app.typeKey(.escape, modifierFlags: [])
- XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a task")
+ XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a item")
}
- // MARK: - Task Creation
+ // MARK: - Item Creation
func testCmdNFocusesDraftField() {
app.typeKey("n", modifierFlags: .command)
@@ -124,61 +124,61 @@ final class ListlessMacUITests: XCTestCase {
textField.typeText("Focused item")
textField.typeKey(.return, modifierFlags: [])
XCTAssertTrue(
- taskText("Focused item").waitForExistence(timeout: 2),
- "Typing without clicking should commit the task if draft field has focus"
+ itemText("Focused item").waitForExistence(timeout: 2),
+ "Typing without clicking should commit the item if draft field has focus"
)
}
- func testCreateTaskViaMenuShortcut() {
- createTask("Buy groceries")
+ func testCreateItemViaMenuShortcut() {
+ createItem("Buy groceries")
XCTAssertTrue(
- taskText("Buy groceries").waitForExistence(timeout: 2),
- "Task should appear with the typed title"
+ itemText("Buy groceries").waitForExistence(timeout: 2),
+ "Item should appear with the typed title"
)
}
- func testReturnChainsNewTask() {
- createTask("First item")
+ func testReturnChainsNewItem() {
+ createItem("First item")
XCTAssertTrue(
draftTextField.waitForExistence(timeout: 2),
"New draft text field should appear after Return"
)
}
- func testCreateMultipleTasks() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
+ func testCreateMultipleItems() {
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
app.typeKey(.escape, modifierFlags: [])
- XCTAssertTrue(taskText("Alpha").waitForExistence(timeout: 2))
- XCTAssertTrue(taskText("Bravo").exists)
- XCTAssertTrue(taskText("Charlie").exists)
+ XCTAssertTrue(itemText("Alpha").waitForExistence(timeout: 2))
+ XCTAssertTrue(itemText("Bravo").exists)
+ XCTAssertTrue(itemText("Charlie").exists)
}
- func testEmptyTaskDeletedOnCommit() {
+ func testEmptyItemDeletedOnCommit() {
app.typeKey("n", modifierFlags: .command)
XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
app.typeKey(.escape, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "Empty state should reappear when empty task is discarded"
+ "Empty state should reappear when empty item is discarded"
)
}
- // MARK: - Task Completion
+ // MARK: - Item Completion
- func testCompleteTaskViaCheckbox() {
- createTask("Finish report")
+ func testCompleteItemViaCheckbox() {
+ createItem("Finish report")
app.typeKey(.escape, modifierFlags: [])
- let checkbox = taskCheckbox(at: 0)
+ let checkbox = itemCheckbox(at: 0)
XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
XCTAssertEqual(checkbox.value as? String, "circle")
checkbox.click()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(
completed.waitForExistence(timeout: 3),
@@ -186,20 +186,20 @@ final class ListlessMacUITests: XCTestCase {
)
}
- func testUncompleteTask() {
- createTask("Finish report")
+ func testUncompleteItem() {
+ createItem("Finish report")
app.typeKey(.escape, modifierFlags: [])
- taskCheckbox(at: 0).click()
+ itemCheckbox(at: 0).click()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(completed.waitForExistence(timeout: 3))
completed.click()
let uncompleted = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'circle'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'circle'")
).firstMatch
XCTAssertTrue(
uncompleted.waitForExistence(timeout: 3),
@@ -207,38 +207,38 @@ final class ListlessMacUITests: XCTestCase {
)
}
- // MARK: - Task Deletion
+ // MARK: - Item Deletion
- func testDeleteTaskViaBackspace() {
- createTask("Delete me")
- navigateToTask(at: 0)
+ func testDeleteItemViaBackspace() {
+ createItem("Delete me")
+ navigateToItem(at: 0)
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "Empty state should reappear after deleting the only task"
+ "Empty state should reappear after deleting the only item"
)
}
func testArrowKeyNavigationThenDelete() {
- createTask("Keep me")
- createTask("Delete me")
- navigateToTask(at: 1)
+ createItem("Keep me")
+ createItem("Delete me")
+ navigateToItem(at: 1)
app.typeKey(.delete, modifierFlags: [])
- XCTAssertTrue(taskText("Keep me").waitForExistence(timeout: 2), "First task should remain")
- XCTAssertFalse(taskText("Delete me").exists, "Second task should be deleted")
+ XCTAssertTrue(itemText("Keep me").waitForExistence(timeout: 2), "First item should remain")
+ XCTAssertFalse(itemText("Delete me").exists, "Second item should be deleted")
}
// MARK: - Reordering
- func testMoveTaskUp() {
- createTask("Alpha")
- createTask("Bravo")
- navigateToTask(at: 1)
+ func testMoveItemUp() {
+ createItem("Alpha")
+ createItem("Bravo")
+ navigateToItem(at: 1)
app.typeKey(.upArrow, modifierFlags: .command)
- let bravo = taskText("Bravo")
- let alpha = taskText("Alpha")
+ let bravo = itemText("Bravo")
+ let alpha = itemText("Alpha")
XCTAssertTrue(bravo.waitForExistence(timeout: 2))
XCTAssertTrue(alpha.exists)
XCTAssertLessThan(
@@ -247,14 +247,14 @@ final class ListlessMacUITests: XCTestCase {
)
}
- func testMoveTaskDown() {
- createTask("Alpha")
- createTask("Bravo")
- navigateToTask(at: 0)
+ func testMoveItemDown() {
+ createItem("Alpha")
+ createItem("Bravo")
+ navigateToItem(at: 0)
app.typeKey(.downArrow, modifierFlags: .command)
- let alpha = taskText("Alpha")
- let bravo = taskText("Bravo")
+ let alpha = itemText("Alpha")
+ let bravo = itemText("Bravo")
XCTAssertTrue(alpha.waitForExistence(timeout: 2))
XCTAssertTrue(bravo.exists)
XCTAssertGreaterThan(
@@ -266,50 +266,50 @@ final class ListlessMacUITests: XCTestCase {
// MARK: - Select All
func testSelectAllThenDelete() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
app.typeKey("a", modifierFlags: .command)
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "All tasks should be deleted after Select All + Delete"
+ "All items should be deleted after Select All + Delete"
)
}
- func testSelectAllIncludesCompletedTasks() {
- createTask("Active item")
- createTask("Done item")
+ func testSelectAllIncludesCompletedItems() {
+ createItem("Active item")
+ createItem("Done item")
app.typeKey(.escape, modifierFlags: [])
- // Complete the second task
- taskCheckbox(at: 1).click()
+ // Complete the second item
+ itemCheckbox(at: 1).click()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(completed.waitForExistence(timeout: 3))
- // Navigate to first task, then select all and delete
- navigateToTask(at: 0)
+ // Navigate to first item, then select all and delete
+ navigateToItem(at: 0)
app.typeKey("a", modifierFlags: .command)
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "Both active and completed tasks should be deleted after Select All + Delete"
+ "Both active and completed items should be deleted after Select All + Delete"
)
}
// MARK: - Clear Completed
func testClearCompleted() {
- createTask("Done task")
+ createItem("Done item")
app.typeKey(.escape, modifierFlags: [])
- taskCheckbox(at: 0).click()
+ itemCheckbox(at: 0).click()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(completed.waitForExistence(timeout: 3))
@@ -318,32 +318,32 @@ final class ListlessMacUITests: XCTestCase {
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 3),
- "Empty state should reappear after clearing the only completed task"
+ "Empty state should reappear after clearing the only completed item"
)
}
// MARK: - Shift+Arrow Selection
func testShiftDownExtendsSelection() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
app.typeKey(.downArrow, modifierFlags: .shift)
app.typeKey(.downArrow, modifierFlags: .shift)
// All three should be selected; delete removes them all.
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "All three tasks should be deleted after Shift+Down range select"
+ "All three items should be deleted after Shift+Down range select"
)
}
func testShiftUpContractsSelection() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
// Extend down to select Alpha, Bravo, Charlie
app.typeKey(.downArrow, modifierFlags: .shift)
app.typeKey(.downArrow, modifierFlags: .shift)
@@ -352,16 +352,16 @@ final class ListlessMacUITests: XCTestCase {
app.typeKey(.upArrow, modifierFlags: .shift)
// Only Alpha should be selected now.
app.typeKey(.delete, modifierFlags: [])
- XCTAssertTrue(taskText("Bravo").waitForExistence(timeout: 2), "Bravo should remain")
- XCTAssertTrue(taskText("Charlie").exists, "Charlie should remain")
- XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted")
+ XCTAssertTrue(itemText("Bravo").waitForExistence(timeout: 2), "Bravo should remain")
+ XCTAssertTrue(itemText("Charlie").exists, "Charlie should remain")
+ XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
}
func testSelectAllThenShiftDown() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
// Select all via Cmd+A
app.typeKey("a", modifierFlags: .command)
// Shift+Down should be a no-op (cursor already at last item).
@@ -370,17 +370,17 @@ final class ListlessMacUITests: XCTestCase {
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "All tasks should still be selected after Shift+Down at end"
+ "All items should still be selected after Shift+Down at end"
)
}
// MARK: - Cmd+Click Selection
func testCmdClickDeselectsFromRange() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
// Extend selection to all three
app.typeKey(.downArrow, modifierFlags: .shift)
app.typeKey(.downArrow, modifierFlags: .shift)
@@ -389,36 +389,36 @@ final class ListlessMacUITests: XCTestCase {
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
- taskText("Bravo").waitForExistence(timeout: 2),
+ itemText("Bravo").waitForExistence(timeout: 2),
"Bravo should remain (was deselected by Cmd+Click)"
)
- XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted")
- XCTAssertFalse(taskText("Charlie").exists, "Charlie should be deleted")
+ XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
+ XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted")
}
func testCmdClickAddsToSelection() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
// Cmd+Click Charlie to add it to selection
cmdClickRow(withText: "Charlie")
app.typeKey(.delete, modifierFlags: [])
XCTAssertTrue(
- taskText("Bravo").waitForExistence(timeout: 2),
+ itemText("Bravo").waitForExistence(timeout: 2),
"Bravo should remain (was not selected)"
)
- XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted")
- XCTAssertFalse(taskText("Charlie").exists, "Charlie should be deleted")
+ XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted")
+ XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted")
}
func testShiftUpAfterCmdClickDeselect() {
- createTask("Delta")
- createTask("Echo")
- createTask("Foxtrot")
- createTask("Golf")
- navigateToTask(at: 0)
+ createItem("Delta")
+ createItem("Echo")
+ createItem("Foxtrot")
+ createItem("Golf")
+ navigateToItem(at: 0)
// Select Delta through Golf
app.typeKey(.downArrow, modifierFlags: .shift)
app.typeKey(.downArrow, modifierFlags: .shift)
@@ -429,17 +429,17 @@ final class ListlessMacUITests: XCTestCase {
app.typeKey(.upArrow, modifierFlags: .shift)
app.typeKey(.delete, modifierFlags: [])
- XCTAssertTrue(taskText("Echo").waitForExistence(timeout: 2), "Echo should remain")
- XCTAssertTrue(taskText("Golf").exists, "Golf should remain")
- XCTAssertFalse(taskText("Delta").exists, "Delta should be deleted")
- XCTAssertFalse(taskText("Foxtrot").exists, "Foxtrot should be deleted")
+ XCTAssertTrue(itemText("Echo").waitForExistence(timeout: 2), "Echo should remain")
+ XCTAssertTrue(itemText("Golf").exists, "Golf should remain")
+ XCTAssertFalse(itemText("Delta").exists, "Delta should be deleted")
+ XCTAssertFalse(itemText("Foxtrot").exists, "Foxtrot should be deleted")
}
func testSelectAllAfterCmdClick() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
- navigateToTask(at: 0)
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
+ navigateToItem(at: 0)
app.typeKey(.downArrow, modifierFlags: .shift)
app.typeKey(.downArrow, modifierFlags: .shift)
// Cmd+Click Bravo to create discontinuous selection {Alpha, Charlie}
@@ -450,7 +450,7 @@ final class ListlessMacUITests: XCTestCase {
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "All tasks should be deleted after Select All"
+ "All items should be deleted after Select All"
)
}
}
diff --git a/Tests/UI/ListlessiOSUITests.swift b/Tests/UI/ListlessiOSUITests.swift
@@ -21,9 +21,9 @@ final class ListlessiOSUITests: XCTestCase {
app.staticTexts["Pull down to create"]
}
- /// The main scroll view area; tapping empty space here creates a draft task.
- var taskListScrollView: XCUIElement {
- app.scrollViews["task-list-scrollview"]
+ /// The main scroll view area; tapping empty space here creates a draft item.
+ var itemListScrollView: XCUIElement {
+ app.scrollViews["item-list-scrollview"]
}
/// The draft row text view that appears after tapping empty space.
@@ -32,19 +32,19 @@ final class ListlessiOSUITests: XCTestCase {
app.textViews.matching(identifier: "draft-row-append").firstMatch
}
- /// Returns the static text for a committed task with the given title.
- func taskText(_ title: String) -> XCUIElement {
+ /// Returns the static text for a committed item with the given title.
+ func itemText(_ title: String) -> XCUIElement {
app.staticTexts.matching(
- NSPredicate(format: "identifier BEGINSWITH 'task-text-' AND label == %@", title)
+ NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND label == %@", title)
).firstMatch
}
- /// Creates a task by tapping empty space to reveal the draft field,
+ /// Creates a item by tapping empty space to reveal the draft field,
/// typing a title, and pressing Return.
- func createTask(_ title: String) {
+ func createItem(_ title: String) {
let textView = draftTextField
if !textView.exists {
- taskListScrollView.tap()
+ itemListScrollView.tap()
if !textView.waitForExistence(timeout: 2) {
XCTFail("Draft text view should appear after tapping empty space")
return
@@ -55,11 +55,11 @@ final class ListlessiOSUITests: XCTestCase {
}
/// Returns the Nth checkbox button (0-indexed).
- func taskCheckbox(at index: Int) -> XCUIElement {
- app.buttons.matching(identifier: "task-checkbox").element(boundBy: index)
+ func itemCheckbox(at index: Int) -> XCUIElement {
+ app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
}
- /// Exits editing mode. After createTask, the new draft text view is
+ /// Exits editing mode. After createItem, the new draft text view is
/// focused. Dismiss it by tapping the scroll view background (which
/// calls handleBackgroundTap to commit/dismiss the empty draft).
func exitEditingMode() {
@@ -68,7 +68,7 @@ final class ListlessiOSUITests: XCTestCase {
draft.typeText("\n")
}
// Tap background to deselect
- taskListScrollView.tap()
+ itemListScrollView.tap()
}
// MARK: - Empty State
@@ -80,65 +80,65 @@ final class ListlessiOSUITests: XCTestCase {
)
}
- func testEmptyStateDisappearsAfterCreatingTask() {
- createTask("First item")
+ func testEmptyStateDisappearsAfterCreatingItem() {
+ createItem("First item")
exitEditingMode()
- XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a task")
+ XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a item")
}
- // MARK: - Task Creation
+ // MARK: - Item Creation
- func testCreateTaskViaTap() {
- createTask("Buy groceries")
+ func testCreateItemViaTap() {
+ createItem("Buy groceries")
exitEditingMode()
XCTAssertTrue(
- taskText("Buy groceries").waitForExistence(timeout: 2),
- "Task should appear with the typed title"
+ itemText("Buy groceries").waitForExistence(timeout: 2),
+ "Item should appear with the typed title"
)
}
- func testReturnChainsNewTask() {
- createTask("First item")
+ func testReturnChainsNewItem() {
+ createItem("First item")
XCTAssertTrue(
draftTextField.waitForExistence(timeout: 2),
"New draft text view should appear after Return"
)
}
- func testCreateMultipleTasks() {
- createTask("Alpha")
- createTask("Bravo")
- createTask("Charlie")
+ func testCreateMultipleItems() {
+ createItem("Alpha")
+ createItem("Bravo")
+ createItem("Charlie")
exitEditingMode()
- XCTAssertTrue(taskText("Alpha").waitForExistence(timeout: 2))
- XCTAssertTrue(taskText("Bravo").exists)
- XCTAssertTrue(taskText("Charlie").exists)
+ XCTAssertTrue(itemText("Alpha").waitForExistence(timeout: 2))
+ XCTAssertTrue(itemText("Bravo").exists)
+ XCTAssertTrue(itemText("Charlie").exists)
}
- func testEmptyTaskDeletedOnCommit() {
- taskListScrollView.tap()
+ func testEmptyItemDeletedOnCommit() {
+ itemListScrollView.tap()
XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
draftTextField.typeText("\n")
XCTAssertTrue(
emptyStateLabel.waitForExistence(timeout: 2),
- "Empty state should reappear when empty task is discarded"
+ "Empty state should reappear when empty item is discarded"
)
}
- // MARK: - Task Completion
+ // MARK: - Item Completion
- func testCompleteTaskViaCheckbox() {
- createTask("Finish report")
+ func testCompleteItemViaCheckbox() {
+ createItem("Finish report")
exitEditingMode()
- let checkbox = taskCheckbox(at: 0)
+ let checkbox = itemCheckbox(at: 0)
XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
XCTAssertEqual(checkbox.value as? String, "circle")
checkbox.tap()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(
completed.waitForExistence(timeout: 3),
@@ -146,20 +146,20 @@ final class ListlessiOSUITests: XCTestCase {
)
}
- func testUncompleteTask() {
- createTask("Finish report")
+ func testUncompleteItem() {
+ createItem("Finish report")
exitEditingMode()
- taskCheckbox(at: 0).tap()
+ itemCheckbox(at: 0).tap()
let completed = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'")
).firstMatch
XCTAssertTrue(completed.waitForExistence(timeout: 3))
completed.tap()
let uncompleted = app.buttons.matching(
- NSPredicate(format: "identifier == 'task-checkbox' AND value == 'circle'")
+ NSPredicate(format: "identifier == 'item-checkbox' AND value == 'circle'")
).firstMatch
XCTAssertTrue(
uncompleted.waitForExistence(timeout: 3),
diff --git a/Tests/Unit/ItemStoreCompletionTests.swift b/Tests/Unit/ItemStoreCompletionTests.swift
@@ -0,0 +1,189 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemStore Completion Behavior", .serialized)
+@MainActor
+struct ItemStoreCompletionTests {
+
+ // MARK: - Basic Completion Tests
+
+ @Test("Complete item")
+ func completeItem() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Task to complete")
+
+ try store.complete(itemID: item.id)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.isCompleted == true)
+ }
+
+ @Test("Uncomplete item")
+ func uncompleteItem() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Item")
+ try store.complete(itemID: item.id)
+
+ try store.uncomplete(itemID: item.id)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.isCompleted == false)
+ }
+
+ @Test("Complete with invalid ID does nothing")
+ func completeWithInvalidIDDoesNothing() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Item")
+ let invalidID = UUID()
+
+ try store.complete(itemID: invalidID)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.isCompleted == false)
+ }
+
+ @Test("Uncomplete with invalid ID does nothing")
+ func uncompleteWithInvalidIDDoesNothing() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Item")
+ try store.complete(itemID: item.id)
+ let invalidID = UUID()
+
+ try store.uncomplete(itemID: invalidID)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.isCompleted == true)
+ }
+
+ // MARK: - Timestamp Tests
+
+ @Test("Completing item updates timestamp")
+ func completingItemUpdatesTimestamp() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Item")
+ let originalUpdatedAt = item.updatedAt
+
+ // Small delay to ensure timestamp difference
+ try await Task.sleep(nanoseconds: 10_000_000) // 10ms
+
+ try store.complete(itemID: item.id)
+
+ let items = try store.fetchItems()
+ let updatedItem = items.first
+ #expect(updatedItem?.updatedAt ?? Date() > originalUpdatedAt)
+ }
+
+ // MARK: - Sorting Tests
+
+ @Test("Active items appear before completed items")
+ func activeItemsAppearBeforeCompletedItems() async throws {
+ let store = makeTestStore()
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+ let item3 = try store.createItem(title: "Item 3")
+
+ try store.complete(itemID: item2.id)
+
+ let items = try store.fetchItems()
+ #expect(items[0].id == item1.id)
+ #expect(items[1].id == item3.id)
+ #expect(items[2].id == item2.id)
+ }
+
+ @Test("Completed items sorted by completedOrder")
+ func completedItemsSortedByCompletedOrder() async throws {
+ let store = makeTestStore()
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+ let item3 = try store.createItem(title: "Item 3")
+
+ // Complete in specific order
+ try store.complete(itemID: item2.id)
+ try store.complete(itemID: item1.id)
+ try store.complete(itemID: item3.id)
+
+ let items = try store.fetchItems()
+ // All completed, should be sorted by completedOrder (most recently completed first)
+ #expect(items[0].id == item3.id)
+ #expect(items[1].id == item1.id)
+ #expect(items[2].id == item2.id)
+ }
+
+ @Test("Toggle completion multiple times")
+ func toggleCompletionMultipleTimes() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Item")
+
+ try store.complete(itemID: item.id)
+ var items = try store.fetchItems()
+ #expect(items.first?.isCompleted == true)
+
+ try store.uncomplete(itemID: item.id)
+ items = try store.fetchItems()
+ #expect(items.first?.isCompleted == false)
+
+ try store.complete(itemID: item.id)
+ items = try store.fetchItems()
+ #expect(items.first?.isCompleted == true)
+ }
+
+ @Test("Complete all items")
+ func completeAllItems() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 5)
+
+ for id in itemIDs {
+ try store.complete(itemID: id)
+ }
+
+ let items = try store.fetchItems()
+ #expect(items.allSatisfy { $0.isCompleted })
+ #expect(items.count == 5)
+ }
+
+ @Test("Uncomplete restores previous sortOrder when no active conflict")
+ func uncompleteRestoresPreviousSortOrderWhenNoConflict() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ let itemToRestoreID = itemIDs[1]
+
+ let originalSortOrder = try store.fetchItems().first { $0.id == itemToRestoreID }?.sortOrder
+ #expect(originalSortOrder != nil)
+
+ try store.complete(itemID: itemToRestoreID)
+ try store.uncomplete(itemID: itemToRestoreID)
+
+ let activeItems = try store.fetchItems().filter { !$0.isCompleted }
+ let restoredItem = activeItems.first { $0.id == itemToRestoreID }
+
+ #expect(restoredItem != nil)
+ #expect(restoredItem?.sortOrder == originalSortOrder)
+ #expect(activeItems.count == 3)
+ }
+
+ @Test("Uncomplete appends item when restored sortOrder conflicts with active item")
+ func uncompleteAppendsWhenRestoredSortOrderConflicts() async throws {
+ let store = makeTestStore()
+ let activeItem = try store.createItem(title: "Active item")
+ let completedItem = try store.createItem(title: "Completed item")
+
+ try store.complete(itemID: completedItem.id)
+ try store.moveItem(itemID: activeItem.id, toIndex: 0)
+ completedItem.sortOrder = activeItem.sortOrder
+ try store.save()
+
+ try store.uncomplete(itemID: completedItem.id)
+
+ let activeItems = try store.fetchItems().filter { !$0.isCompleted }
+ .sorted { $0.sortOrder < $1.sortOrder }
+ let lastActiveItem = activeItems.last
+
+ #expect(activeItems.count == 2)
+ #expect(lastActiveItem?.id == completedItem.id)
+ #expect(lastActiveItem?.sortOrder ?? 0 > activeItem.sortOrder)
+ }
+}
diff --git a/Tests/Unit/ItemStoreEdgeCaseTests.swift b/Tests/Unit/ItemStoreEdgeCaseTests.swift
@@ -0,0 +1,285 @@
+import Foundation
+import CoreData
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemStore Edge Cases", .serialized)
+@MainActor
+struct ItemStoreEdgeCaseTests {
+
+ // MARK: - Title Edge Cases
+
+ @Test("Task with empty title")
+ func itemWithEmptyTitle() async throws {
+ let store = makeTestStore()
+
+ let item = try store.createItem(title: "")
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == "")
+ }
+
+ @Test("Task with very long title")
+ func itemWithVeryLongTitle() async throws {
+ let store = makeTestStore()
+ let longTitle = String(repeating: "A", count: 10_000)
+
+ let item = try store.createItem(title: longTitle)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title.count == 10_000)
+ }
+
+ @Test("Task with special characters")
+ func itemWithSpecialCharacters() async throws {
+ let store = makeTestStore()
+ let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()"
+
+ let item = try store.createItem(title: specialTitle)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == specialTitle)
+ }
+
+ @Test("Task with newlines and tabs")
+ func itemWithNewlinesAndTabs() async throws {
+ let store = makeTestStore()
+ let multilineTitle = "Line 1\nLine 2\tTabbed"
+
+ let item = try store.createItem(title: multilineTitle)
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == multilineTitle)
+ }
+
+ // MARK: - Large Data Sets
+
+ @Test("Create many items")
+ func createManyItems() async throws {
+ let store = makeTestStore()
+ let count = 100
+
+ for i in 0..<count {
+ _ = try store.createItem(title: "Task \(i)")
+ }
+
+ let items = try store.fetchItems()
+ #expect(items.count == count)
+ }
+
+ @Test("Delete all items from large set")
+ func deleteAllItemsFromLargeSet() async throws {
+ let store = makeTestStore()
+ var itemIDs: [UUID] = []
+
+ for i in 0..<50 {
+ let item = try store.createItem(title: "Task \(i)")
+ itemIDs.append(item.id)
+ }
+
+ for id in itemIDs {
+ try store.delete(itemID: id)
+ }
+
+ let items = try store.fetchItems()
+ #expect(items.isEmpty)
+ }
+
+ // MARK: - State Transitions
+
+ @Test("Create item after completing all items")
+ func createItemAfterCompletingAllItems() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+
+ for id in itemIDs {
+ try store.complete(itemID: id)
+ }
+
+ let newItem = try store.createItem(title: "New item")
+
+ let items = try store.fetchItems()
+ let activeItems = items.filter { !$0.isCompleted }
+ #expect(activeItems.count == 1)
+ #expect(activeItems[0].id == newItem.id)
+ }
+
+ @Test("Rapid updates to same item")
+ func rapidUpdatesToSameItem() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Original")
+
+ for i in 0..<10 {
+ try store.update(itemID: item.id, title: "Update \(i)")
+ }
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == "Update 9")
+ }
+
+ // MARK: - Store State Tests
+
+ @Test("Store with only completed items")
+ func storeWithOnlyCompletedItems() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 5)
+
+ for id in itemIDs {
+ try store.complete(itemID: id)
+ }
+
+ let items = try store.fetchItems()
+ #expect(items.allSatisfy { $0.isCompleted })
+ #expect(items.count == 5)
+ }
+
+ @Test("SortOrder after completing all items")
+ func sortOrderAfterCompletingAllItems() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+
+ for id in itemIDs {
+ try store.complete(itemID: id)
+ }
+
+ let newItem1 = try store.createItem(title: "New 1")
+ let newItem2 = try store.createItem(title: "New 2")
+
+ let activeItems = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(activeItems[0].id == newItem1.id)
+ #expect(activeItems[1].id == newItem2.id)
+ #expect(activeItems[1].sortOrder > activeItems[0].sortOrder)
+ }
+
+ @Test("Uncompleting item moves it back to active")
+ func uncompletingItemMovesItBackToActive() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ try store.complete(itemID: itemIDs[1])
+
+ try store.uncomplete(itemID: itemIDs[1])
+
+ let items = try store.fetchItems()
+ let activeItems = items.filter { !$0.isCompleted }
+ #expect(activeItems.count == 3)
+ #expect(activeItems.contains { $0.id == itemIDs[1] })
+ }
+
+ @Test("Uncomplete legacy sortOrder zero conflict appends to end")
+ func uncompleteLegacyZeroConflictAppendsToEnd() async throws {
+ let store = makeTestStore()
+ let activeItem = try store.createItem(title: "Active")
+ let completedItem = try store.createItem(title: "Completed")
+
+ activeItem.sortOrder = 0
+ try store.complete(itemID: completedItem.id)
+ completedItem.sortOrder = 0
+ try store.save()
+
+ try store.uncomplete(itemID: completedItem.id)
+
+ let activeItems = try store.fetchItems().filter { !$0.isCompleted }
+ .sorted { $0.sortOrder < $1.sortOrder }
+ #expect(activeItems.count == 2)
+ #expect(activeItems[0].id == activeItem.id)
+ #expect(activeItems[1].id == completedItem.id)
+ #expect(activeItems[1].sortOrder > activeItems[0].sortOrder)
+ }
+
+ @Test("Merge policy prefers store when store updatedAt is newer")
+ func mergePolicyPrefersStoreWhenStoreIsNewer() async throws {
+ let controller = PersistenceController(inMemory: true)
+ let viewContext = controller.viewContext
+ viewContext.automaticallyMergesChangesFromParent = false
+
+ let item = ItemEntity(context: viewContext)
+ item.title = "Original"
+ item.updatedAt = Date(timeIntervalSince1970: 100)
+ try viewContext.save()
+
+ let itemID = item.id
+
+ let backgroundContext = controller.container.newBackgroundContext()
+ try await backgroundContext.perform {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let remote = try backgroundContext.fetch(request).first else {
+ Issue.record("Expected item to exist in background context")
+ return
+ }
+
+ remote.title = "Remote newer"
+ remote.updatedAt = Date(timeIntervalSince1970: 200)
+ try backgroundContext.save()
+ }
+
+ item.updatedAt = Date(timeIntervalSince1970: 150)
+ try viewContext.save()
+
+ let verifyContext = controller.container.newBackgroundContext()
+ try await verifyContext.perform {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let resolved = try verifyContext.fetch(request).first else {
+ Issue.record("Expected item to exist in verify context")
+ return
+ }
+
+ #expect(resolved.title == "Remote newer")
+ #expect(resolved.updatedAt == Date(timeIntervalSince1970: 200))
+ }
+ }
+
+ @Test("Merge policy prefers local when local updatedAt is newer")
+ func mergePolicyPrefersLocalWhenLocalIsNewer() async throws {
+ let controller = PersistenceController(inMemory: true)
+ let viewContext = controller.viewContext
+ viewContext.automaticallyMergesChangesFromParent = false
+
+ let item = ItemEntity(context: viewContext)
+ item.title = "Original"
+ item.updatedAt = Date(timeIntervalSince1970: 100)
+ try viewContext.save()
+
+ let itemID = item.id
+
+ let backgroundContext = controller.container.newBackgroundContext()
+ try await backgroundContext.perform {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let remote = try backgroundContext.fetch(request).first else {
+ Issue.record("Expected item to exist in background context")
+ return
+ }
+
+ remote.title = "Remote older"
+ remote.updatedAt = Date(timeIntervalSince1970: 200)
+ try backgroundContext.save()
+ }
+
+ item.updatedAt = Date(timeIntervalSince1970: 300)
+ try viewContext.save()
+
+ let verifyContext = controller.container.newBackgroundContext()
+ try await verifyContext.perform {
+ let request = ItemEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", itemID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let resolved = try verifyContext.fetch(request).first else {
+ Issue.record("Expected item to exist in verify context")
+ return
+ }
+
+ #expect(resolved.title == "Original")
+ #expect(resolved.updatedAt == Date(timeIntervalSince1970: 300))
+ }
+ }
+}
diff --git a/Tests/Unit/ItemStoreOrderingTests.swift b/Tests/Unit/ItemStoreOrderingTests.swift
@@ -0,0 +1,190 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemStore Task Reordering", .serialized)
+@MainActor
+struct ItemStoreOrderingTests {
+
+ // MARK: - Initial State Tests
+
+ @Test("Initial sortOrder has 1000-point gaps")
+ func initialSortOrderHasThousandPointGaps() async throws {
+ let store = makeTestStore()
+
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+ let item3 = try store.createItem(title: "Item 3")
+
+ let items = try store.fetchItems()
+
+ // All items are active, so they should be the first 3
+ #expect(items.count == 3)
+
+ // Verify items are in ascending order
+ #expect(items[0].sortOrder < items[1].sortOrder)
+ #expect(items[1].sortOrder < items[2].sortOrder)
+
+ // Verify 1000-point gaps between items
+ #expect(items[1].sortOrder - items[0].sortOrder == 1000)
+ #expect(items[2].sortOrder - items[1].sortOrder == 1000)
+ }
+
+ // MARK: - Move Tests (Parameterized)
+
+ @Test("Move item to different positions", arguments: [
+ (from: 0, to: 2),
+ (from: 2, to: 0),
+ (from: 0, to: 1),
+ (from: 1, to: 0),
+ (from: 1, to: 2),
+ (from: 2, to: 1),
+ ])
+ func moveItemToDifferentPositions(from: Int, to: Int) async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ let itemToMove = itemIDs[from]
+
+ try store.moveItem(itemID: itemToMove, toIndex: to)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[to].id == itemToMove)
+ }
+
+ // MARK: - Order Preservation Tests
+
+ @Test("Moving maintains 1000-point gaps")
+ func movingMaintainsThousandPointGaps() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 4)
+
+ try store.moveItem(itemID: itemIDs[0], toIndex: 2)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[0].sortOrder == 0)
+ #expect(items[1].sortOrder == 1000)
+ #expect(items[2].sortOrder == 2000)
+ #expect(items[3].sortOrder == 3000)
+ }
+
+ @Test("Move item to same index does nothing")
+ func moveItemToSameIndexDoesNothing() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ let originalItems = try store.fetchItems().filter { !$0.isCompleted }
+
+ try store.moveItem(itemID: itemIDs[1], toIndex: 1)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[0].id == originalItems[0].id)
+ #expect(items[1].id == originalItems[1].id)
+ #expect(items[2].id == originalItems[2].id)
+ }
+
+ // MARK: - Invalid Input Tests
+
+ @Test("Move with invalid ID does nothing")
+ func moveWithInvalidIDDoesNothing() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ let originalItems = try store.fetchItems().filter { !$0.isCompleted }
+ let invalidID = UUID()
+
+ try store.moveItem(itemID: invalidID, toIndex: 0)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[0].id == originalItems[0].id)
+ #expect(items[1].id == originalItems[1].id)
+ #expect(items[2].id == originalItems[2].id)
+ }
+
+ @Test("Move to negative index clamps to 0")
+ func moveToNegativeIndexClampsToZero() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+
+ try store.moveItem(itemID: itemIDs[2], toIndex: -5)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[0].id == itemIDs[2])
+ }
+
+ @Test("Move to out-of-bounds index clamps to end")
+ func moveToOutOfBoundsIndexClampsToEnd() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+
+ try store.moveItem(itemID: itemIDs[0], toIndex: 999)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[2].id == itemIDs[0])
+ }
+
+ // MARK: - Completed Task Tests
+
+ @Test("Moving only affects active items")
+ func movingOnlyAffectsActiveItems() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 4)
+ try store.complete(itemID: itemIDs[3])
+
+ try store.moveItem(itemID: itemIDs[0], toIndex: 2)
+
+ let allItems = try store.fetchItems()
+ let activeItems = allItems.filter { !$0.isCompleted }
+ let completedItems = allItems.filter { $0.isCompleted }
+
+ #expect(activeItems.count == 3)
+ #expect(completedItems.count == 1)
+ #expect(completedItems[0].id == itemIDs[3])
+ }
+
+ @Test("Moving completed item does nothing")
+ func movingCompletedItemDoesNothing() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 3)
+ try store.complete(itemID: itemIDs[0])
+ let originalItems = try store.fetchItems()
+
+ try store.moveItem(itemID: itemIDs[0], toIndex: 1)
+
+ let items = try store.fetchItems()
+ #expect(items[0].id == originalItems[0].id)
+ #expect(items[1].id == originalItems[1].id)
+ #expect(items[2].id == originalItems[2].id)
+ }
+
+ // MARK: - Edge Cases
+
+ @Test("Move single item does nothing")
+ func moveSingleItemDoesNothing() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Only item")
+
+ try store.moveItem(itemID: item.id, toIndex: 0)
+
+ let items = try store.fetchItems()
+ #expect(items.count == 1)
+ #expect(items[0].id == item.id)
+ }
+
+ @Test("Move in empty store does nothing")
+ func moveInEmptyStoreDoesNothing() async throws {
+ let store = makeTestStore()
+ let randomID = UUID()
+
+ try store.moveItem(itemID: randomID, toIndex: 0)
+
+ let items = try store.fetchItems()
+ #expect(items.isEmpty)
+ }
+
+ @Test("Multiple moves maintain order")
+ func multipleMoveMaintainOrder() async throws {
+ let (store, itemIDs) = try makeTestStoreWithItems(count: 4)
+
+ try store.moveItem(itemID: itemIDs[0], toIndex: 3)
+ try store.moveItem(itemID: itemIDs[2], toIndex: 0)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+ #expect(items[0].id == itemIDs[2])
+ #expect(items[3].id == itemIDs[0])
+ }
+}
diff --git a/Tests/Unit/ItemStoreTests.swift b/Tests/Unit/ItemStoreTests.swift
@@ -0,0 +1,208 @@
+import Foundation
+import Testing
+
+#if os(macOS)
+@testable import Listless
+#else
+@testable import Listless_iOS
+#endif
+
+@Suite("ItemStore CRUD Operations", .serialized)
+@MainActor
+struct ItemStoreTests {
+
+ // MARK: - Creation Tests
+
+ @Test("Create item with empty title")
+ func createItemWithEmptyTitle() async throws {
+ let store = makeTestStore()
+
+ let item = try store.createItem()
+
+ #expect(item.title == "")
+ #expect(item.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
+ #expect(item.isCompleted == false)
+ #expect(item.createdAt.timeIntervalSinceNow > -1.0)
+ }
+
+ @Test("Create item with title")
+ func createItemWithTitle() async throws {
+ let store = makeTestStore()
+
+ let item = try store.createItem(title: "Buy groceries")
+
+ #expect(item.title == "Buy groceries")
+ #expect(item.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
+ }
+
+ @Test("Create multiple items with unique IDs")
+ func createMultipleItemsWithUniqueIDs() async throws {
+ let store = makeTestStore()
+
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+ let item3 = try store.createItem(title: "Item 3")
+
+ #expect(item1.id != item2.id)
+ #expect(item2.id != item3.id)
+ #expect(item1.id != item3.id)
+ }
+
+ @Test("Created item has timestamps")
+ func createdItemHasTimestamps() async throws {
+ let store = makeTestStore()
+
+ let beforeCreate = Date()
+ let item = try store.createItem(title: "Test")
+ let afterCreate = Date()
+
+ #expect(item.createdAt >= beforeCreate)
+ #expect(item.createdAt <= afterCreate)
+ #expect(item.updatedAt >= beforeCreate)
+ #expect(item.updatedAt <= afterCreate)
+ }
+
+ @Test("Create item at beginning prepends to active items")
+ func createItemAtBeginningPrepends() async throws {
+ let store = makeTestStore()
+
+ let first = try store.createItem(title: "First")
+ let second = try store.createItem(title: "Second")
+ let prepended = try store.createItem(title: "Prepended", atBeginning: true)
+
+ let items = try store.fetchItems().filter { !$0.isCompleted }
+
+ #expect(items.map(\.title) == ["Prepended", "First", "Second"])
+ #expect(prepended.sortOrder < first.sortOrder)
+ #expect(first.sortOrder < second.sortOrder)
+ }
+
+ // MARK: - Fetch Tests
+
+ @Test("Fetch items from empty store")
+ func fetchItemsFromEmptyStore() async throws {
+ let store = makeTestStore()
+
+ let items = try store.fetchItems()
+
+ #expect(items.isEmpty)
+ }
+
+ @Test("Fetch items returns created items")
+ func fetchItemsReturnsCreatedItems() async throws {
+ let store = makeTestStore()
+ _ = try store.createItem(title: "Item 1")
+ _ = try store.createItem(title: "Item 2")
+
+ let items = try store.fetchItems()
+
+ #expect(items.count == 2)
+ #expect(items[0].title == "Item 1")
+ #expect(items[1].title == "Item 2")
+ }
+
+ // MARK: - Update Tests
+
+ @Test("Update item title")
+ func updateItemTitle() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Original")
+
+ try store.update(itemID: item.id, title: "Updated")
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == "Updated")
+ }
+
+ @Test("Update item title without saving")
+ func updateItemTitleWithoutSaving() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Original")
+
+ try store.updateWithoutSaving(itemID: item.id, title: "Updated")
+
+ let items = try store.fetchItems()
+ #expect(items.first?.title == "Updated")
+ }
+
+ @Test("Update with invalid ID does nothing")
+ func updateWithInvalidIDDoesNothing() async throws {
+ let store = makeTestStore()
+ _ = try store.createItem(title: "Item 1")
+ let invalidID = UUID()
+
+ try store.update(itemID: invalidID, title: "Should not exist")
+
+ let items = try store.fetchItems()
+ #expect(items.count == 1)
+ #expect(items.first?.title == "Item 1")
+ }
+
+ // MARK: - Delete Tests
+
+ @Test("Delete item")
+ func deleteItem() async throws {
+ let store = makeTestStore()
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+
+ try store.delete(itemID: item1.id)
+
+ let items = try store.fetchItems()
+ #expect(items.count == 1)
+ #expect(items.first?.id == item2.id)
+ }
+
+ @Test("Delete all items")
+ func deleteAllItems() async throws {
+ let store = makeTestStore()
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+
+ try store.delete(itemID: item1.id)
+ try store.delete(itemID: item2.id)
+
+ let items = try store.fetchItems()
+ #expect(items.isEmpty)
+ }
+
+ @Test("Delete with invalid ID does nothing")
+ func deleteWithInvalidIDDoesNothing() async throws {
+ let store = makeTestStore()
+ _ = try store.createItem(title: "Item 1")
+ let invalidID = UUID()
+
+ try store.delete(itemID: invalidID)
+
+ let items = try store.fetchItems()
+ #expect(items.count == 1)
+ }
+
+ // MARK: - Edge Cases
+
+ @Test("Task IDs persist across fetches")
+ func itemIDsPersistAcrossFetches() async throws {
+ let store = makeTestStore()
+ let item = try store.createItem(title: "Test")
+ let originalID = item.id
+
+ let fetchedItems = try store.fetchItems()
+ let fetchedID = fetchedItems.first?.id
+
+ #expect(fetchedID == originalID)
+ }
+
+ @Test("Create item increments sortOrder")
+ func createItemIncrementsSortOrder() async throws {
+ let store = makeTestStore()
+
+ let item1 = try store.createItem(title: "Item 1")
+ let item2 = try store.createItem(title: "Item 2")
+ let item3 = try store.createItem(title: "Item 3")
+
+ #expect(item2.sortOrder > item1.sortOrder)
+ #expect(item3.sortOrder > item2.sortOrder)
+ #expect(item2.sortOrder - item1.sortOrder == 1000)
+ #expect(item3.sortOrder - item2.sortOrder == 1000)
+ }
+}
diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift
@@ -1,189 +0,0 @@
-import Foundation
-import Testing
-
-#if os(macOS)
-@testable import Listless
-#else
-@testable import Listless_iOS
-#endif
-
-@Suite("TaskStore Completion Behavior", .serialized)
-@MainActor
-struct TaskStoreCompletionTests {
-
- // MARK: - Basic Completion Tests
-
- @Test("Complete task")
- func completeTask() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task to complete")
-
- try store.complete(taskID: task.id)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == true)
- }
-
- @Test("Uncomplete task")
- func uncompleteTask() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task")
- try store.complete(taskID: task.id)
-
- try store.uncomplete(taskID: task.id)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == false)
- }
-
- @Test("Complete with invalid ID does nothing")
- func completeWithInvalidIDDoesNothing() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task")
- let invalidID = UUID()
-
- try store.complete(taskID: invalidID)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == false)
- }
-
- @Test("Uncomplete with invalid ID does nothing")
- func uncompleteWithInvalidIDDoesNothing() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task")
- try store.complete(taskID: task.id)
- let invalidID = UUID()
-
- try store.uncomplete(taskID: invalidID)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == true)
- }
-
- // MARK: - Timestamp Tests
-
- @Test("Completing task updates timestamp")
- func completingTaskUpdatesTimestamp() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task")
- let originalUpdatedAt = task.updatedAt
-
- // Small delay to ensure timestamp difference
- try await Task.sleep(nanoseconds: 10_000_000) // 10ms
-
- try store.complete(taskID: task.id)
-
- let tasks = try store.fetchTasks()
- let updatedTask = tasks.first
- #expect(updatedTask?.updatedAt ?? Date() > originalUpdatedAt)
- }
-
- // MARK: - Sorting Tests
-
- @Test("Active tasks appear before completed tasks")
- func activeTasksAppearBeforeCompletedTasks() async throws {
- let store = makeTestStore()
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
- let task3 = try store.createTask(title: "Task 3")
-
- try store.complete(taskID: task2.id)
-
- let tasks = try store.fetchTasks()
- #expect(tasks[0].id == task1.id)
- #expect(tasks[1].id == task3.id)
- #expect(tasks[2].id == task2.id)
- }
-
- @Test("Completed tasks sorted by completedOrder")
- func completedTasksSortedByCompletedOrder() async throws {
- let store = makeTestStore()
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
- let task3 = try store.createTask(title: "Task 3")
-
- // Complete in specific order
- try store.complete(taskID: task2.id)
- try store.complete(taskID: task1.id)
- try store.complete(taskID: task3.id)
-
- let tasks = try store.fetchTasks()
- // All completed, should be sorted by completedOrder (most recently completed first)
- #expect(tasks[0].id == task3.id)
- #expect(tasks[1].id == task1.id)
- #expect(tasks[2].id == task2.id)
- }
-
- @Test("Toggle completion multiple times")
- func toggleCompletionMultipleTimes() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Task")
-
- try store.complete(taskID: task.id)
- var tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == true)
-
- try store.uncomplete(taskID: task.id)
- tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == false)
-
- try store.complete(taskID: task.id)
- tasks = try store.fetchTasks()
- #expect(tasks.first?.isCompleted == true)
- }
-
- @Test("Complete all tasks")
- func completeAllTasks() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 5)
-
- for id in taskIDs {
- try store.complete(taskID: id)
- }
-
- let tasks = try store.fetchTasks()
- #expect(tasks.allSatisfy { $0.isCompleted })
- #expect(tasks.count == 5)
- }
-
- @Test("Uncomplete restores previous sortOrder when no active conflict")
- func uncompleteRestoresPreviousSortOrderWhenNoConflict() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- let taskToRestoreID = taskIDs[1]
-
- let originalSortOrder = try store.fetchTasks().first { $0.id == taskToRestoreID }?.sortOrder
- #expect(originalSortOrder != nil)
-
- try store.complete(taskID: taskToRestoreID)
- try store.uncomplete(taskID: taskToRestoreID)
-
- let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
- let restoredTask = activeTasks.first { $0.id == taskToRestoreID }
-
- #expect(restoredTask != nil)
- #expect(restoredTask?.sortOrder == originalSortOrder)
- #expect(activeTasks.count == 3)
- }
-
- @Test("Uncomplete appends task when restored sortOrder conflicts with active task")
- func uncompleteAppendsWhenRestoredSortOrderConflicts() async throws {
- let store = makeTestStore()
- let activeTask = try store.createTask(title: "Active task")
- let completedTask = try store.createTask(title: "Completed task")
-
- try store.complete(taskID: completedTask.id)
- try store.moveTask(taskID: activeTask.id, toIndex: 0)
- completedTask.sortOrder = activeTask.sortOrder
- try store.save()
-
- try store.uncomplete(taskID: completedTask.id)
-
- let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
- .sorted { $0.sortOrder < $1.sortOrder }
- let lastActiveTask = activeTasks.last
-
- #expect(activeTasks.count == 2)
- #expect(lastActiveTask?.id == completedTask.id)
- #expect(lastActiveTask?.sortOrder ?? 0 > activeTask.sortOrder)
- }
-}
diff --git a/Tests/Unit/TaskStoreEdgeCaseTests.swift b/Tests/Unit/TaskStoreEdgeCaseTests.swift
@@ -1,285 +0,0 @@
-import Foundation
-import CoreData
-import Testing
-
-#if os(macOS)
-@testable import Listless
-#else
-@testable import Listless_iOS
-#endif
-
-@Suite("TaskStore Edge Cases", .serialized)
-@MainActor
-struct TaskStoreEdgeCaseTests {
-
- // MARK: - Title Edge Cases
-
- @Test("Task with empty title")
- func taskWithEmptyTitle() async throws {
- let store = makeTestStore()
-
- let task = try store.createTask(title: "")
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == "")
- }
-
- @Test("Task with very long title")
- func taskWithVeryLongTitle() async throws {
- let store = makeTestStore()
- let longTitle = String(repeating: "A", count: 10_000)
-
- let task = try store.createTask(title: longTitle)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title.count == 10_000)
- }
-
- @Test("Task with special characters")
- func taskWithSpecialCharacters() async throws {
- let store = makeTestStore()
- let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()"
-
- let task = try store.createTask(title: specialTitle)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == specialTitle)
- }
-
- @Test("Task with newlines and tabs")
- func taskWithNewlinesAndTabs() async throws {
- let store = makeTestStore()
- let multilineTitle = "Line 1\nLine 2\tTabbed"
-
- let task = try store.createTask(title: multilineTitle)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == multilineTitle)
- }
-
- // MARK: - Large Data Sets
-
- @Test("Create many tasks")
- func createManyTasks() async throws {
- let store = makeTestStore()
- let count = 100
-
- for i in 0..<count {
- _ = try store.createTask(title: "Task \(i)")
- }
-
- let tasks = try store.fetchTasks()
- #expect(tasks.count == count)
- }
-
- @Test("Delete all tasks from large set")
- func deleteAllTasksFromLargeSet() async throws {
- let store = makeTestStore()
- var taskIDs: [UUID] = []
-
- for i in 0..<50 {
- let task = try store.createTask(title: "Task \(i)")
- taskIDs.append(task.id)
- }
-
- for id in taskIDs {
- try store.delete(taskID: id)
- }
-
- let tasks = try store.fetchTasks()
- #expect(tasks.isEmpty)
- }
-
- // MARK: - State Transitions
-
- @Test("Create task after completing all tasks")
- func createTaskAfterCompletingAllTasks() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
-
- for id in taskIDs {
- try store.complete(taskID: id)
- }
-
- let newTask = try store.createTask(title: "New task")
-
- let tasks = try store.fetchTasks()
- let activeTasks = tasks.filter { !$0.isCompleted }
- #expect(activeTasks.count == 1)
- #expect(activeTasks[0].id == newTask.id)
- }
-
- @Test("Rapid updates to same task")
- func rapidUpdatesToSameTask() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Original")
-
- for i in 0..<10 {
- try store.update(taskID: task.id, title: "Update \(i)")
- }
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == "Update 9")
- }
-
- // MARK: - Store State Tests
-
- @Test("Store with only completed tasks")
- func storeWithOnlyCompletedTasks() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 5)
-
- for id in taskIDs {
- try store.complete(taskID: id)
- }
-
- let tasks = try store.fetchTasks()
- #expect(tasks.allSatisfy { $0.isCompleted })
- #expect(tasks.count == 5)
- }
-
- @Test("SortOrder after completing all tasks")
- func sortOrderAfterCompletingAllTasks() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
-
- for id in taskIDs {
- try store.complete(taskID: id)
- }
-
- let newTask1 = try store.createTask(title: "New 1")
- let newTask2 = try store.createTask(title: "New 2")
-
- let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(activeTasks[0].id == newTask1.id)
- #expect(activeTasks[1].id == newTask2.id)
- #expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder)
- }
-
- @Test("Uncompleting task moves it back to active")
- func uncompletingTaskMovesItBackToActive() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- try store.complete(taskID: taskIDs[1])
-
- try store.uncomplete(taskID: taskIDs[1])
-
- let tasks = try store.fetchTasks()
- let activeTasks = tasks.filter { !$0.isCompleted }
- #expect(activeTasks.count == 3)
- #expect(activeTasks.contains { $0.id == taskIDs[1] })
- }
-
- @Test("Uncomplete legacy sortOrder zero conflict appends to end")
- func uncompleteLegacyZeroConflictAppendsToEnd() async throws {
- let store = makeTestStore()
- let activeTask = try store.createTask(title: "Active")
- let completedTask = try store.createTask(title: "Completed")
-
- activeTask.sortOrder = 0
- try store.complete(taskID: completedTask.id)
- completedTask.sortOrder = 0
- try store.save()
-
- try store.uncomplete(taskID: completedTask.id)
-
- let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
- .sorted { $0.sortOrder < $1.sortOrder }
- #expect(activeTasks.count == 2)
- #expect(activeTasks[0].id == activeTask.id)
- #expect(activeTasks[1].id == completedTask.id)
- #expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder)
- }
-
- @Test("Merge policy prefers store when store updatedAt is newer")
- func mergePolicyPrefersStoreWhenStoreIsNewer() async throws {
- let controller = PersistenceController(inMemory: true)
- let viewContext = controller.viewContext
- viewContext.automaticallyMergesChangesFromParent = false
-
- let task = TaskItem(context: viewContext)
- task.title = "Original"
- task.updatedAt = Date(timeIntervalSince1970: 100)
- try viewContext.save()
-
- let taskID = task.id
-
- let backgroundContext = controller.container.newBackgroundContext()
- try await backgroundContext.perform {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
- request.fetchLimit = 1
-
- guard let remote = try backgroundContext.fetch(request).first else {
- Issue.record("Expected task to exist in background context")
- return
- }
-
- remote.title = "Remote newer"
- remote.updatedAt = Date(timeIntervalSince1970: 200)
- try backgroundContext.save()
- }
-
- task.updatedAt = Date(timeIntervalSince1970: 150)
- try viewContext.save()
-
- let verifyContext = controller.container.newBackgroundContext()
- try await verifyContext.perform {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
- request.fetchLimit = 1
-
- guard let resolved = try verifyContext.fetch(request).first else {
- Issue.record("Expected task to exist in verify context")
- return
- }
-
- #expect(resolved.title == "Remote newer")
- #expect(resolved.updatedAt == Date(timeIntervalSince1970: 200))
- }
- }
-
- @Test("Merge policy prefers local when local updatedAt is newer")
- func mergePolicyPrefersLocalWhenLocalIsNewer() async throws {
- let controller = PersistenceController(inMemory: true)
- let viewContext = controller.viewContext
- viewContext.automaticallyMergesChangesFromParent = false
-
- let task = TaskItem(context: viewContext)
- task.title = "Original"
- task.updatedAt = Date(timeIntervalSince1970: 100)
- try viewContext.save()
-
- let taskID = task.id
-
- let backgroundContext = controller.container.newBackgroundContext()
- try await backgroundContext.perform {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
- request.fetchLimit = 1
-
- guard let remote = try backgroundContext.fetch(request).first else {
- Issue.record("Expected task to exist in background context")
- return
- }
-
- remote.title = "Remote older"
- remote.updatedAt = Date(timeIntervalSince1970: 200)
- try backgroundContext.save()
- }
-
- task.updatedAt = Date(timeIntervalSince1970: 300)
- try viewContext.save()
-
- let verifyContext = controller.container.newBackgroundContext()
- try await verifyContext.perform {
- let request = TaskItem.fetchRequest()
- request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
- request.fetchLimit = 1
-
- guard let resolved = try verifyContext.fetch(request).first else {
- Issue.record("Expected task to exist in verify context")
- return
- }
-
- #expect(resolved.title == "Original")
- #expect(resolved.updatedAt == Date(timeIntervalSince1970: 300))
- }
- }
-}
diff --git a/Tests/Unit/TaskStoreOrderingTests.swift b/Tests/Unit/TaskStoreOrderingTests.swift
@@ -1,190 +0,0 @@
-import Foundation
-import Testing
-
-#if os(macOS)
-@testable import Listless
-#else
-@testable import Listless_iOS
-#endif
-
-@Suite("TaskStore Task Reordering", .serialized)
-@MainActor
-struct TaskStoreOrderingTests {
-
- // MARK: - Initial State Tests
-
- @Test("Initial sortOrder has 1000-point gaps")
- func initialSortOrderHasThousandPointGaps() async throws {
- let store = makeTestStore()
-
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
- let task3 = try store.createTask(title: "Task 3")
-
- let tasks = try store.fetchTasks()
-
- // All tasks are active, so they should be the first 3
- #expect(tasks.count == 3)
-
- // Verify tasks are in ascending order
- #expect(tasks[0].sortOrder < tasks[1].sortOrder)
- #expect(tasks[1].sortOrder < tasks[2].sortOrder)
-
- // Verify 1000-point gaps between tasks
- #expect(tasks[1].sortOrder - tasks[0].sortOrder == 1000)
- #expect(tasks[2].sortOrder - tasks[1].sortOrder == 1000)
- }
-
- // MARK: - Move Tests (Parameterized)
-
- @Test("Move task to different positions", arguments: [
- (from: 0, to: 2),
- (from: 2, to: 0),
- (from: 0, to: 1),
- (from: 1, to: 0),
- (from: 1, to: 2),
- (from: 2, to: 1),
- ])
- func moveTaskToDifferentPositions(from: Int, to: Int) async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- let taskToMove = taskIDs[from]
-
- try store.moveTask(taskID: taskToMove, toIndex: to)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[to].id == taskToMove)
- }
-
- // MARK: - Order Preservation Tests
-
- @Test("Moving maintains 1000-point gaps")
- func movingMaintainsThousandPointGaps() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
-
- try store.moveTask(taskID: taskIDs[0], toIndex: 2)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[0].sortOrder == 0)
- #expect(tasks[1].sortOrder == 1000)
- #expect(tasks[2].sortOrder == 2000)
- #expect(tasks[3].sortOrder == 3000)
- }
-
- @Test("Move task to same index does nothing")
- func moveTaskToSameIndexDoesNothing() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- let originalTasks = try store.fetchTasks().filter { !$0.isCompleted }
-
- try store.moveTask(taskID: taskIDs[1], toIndex: 1)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[0].id == originalTasks[0].id)
- #expect(tasks[1].id == originalTasks[1].id)
- #expect(tasks[2].id == originalTasks[2].id)
- }
-
- // MARK: - Invalid Input Tests
-
- @Test("Move with invalid ID does nothing")
- func moveWithInvalidIDDoesNothing() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- let originalTasks = try store.fetchTasks().filter { !$0.isCompleted }
- let invalidID = UUID()
-
- try store.moveTask(taskID: invalidID, toIndex: 0)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[0].id == originalTasks[0].id)
- #expect(tasks[1].id == originalTasks[1].id)
- #expect(tasks[2].id == originalTasks[2].id)
- }
-
- @Test("Move to negative index clamps to 0")
- func moveToNegativeIndexClampsToZero() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
-
- try store.moveTask(taskID: taskIDs[2], toIndex: -5)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[0].id == taskIDs[2])
- }
-
- @Test("Move to out-of-bounds index clamps to end")
- func moveToOutOfBoundsIndexClampsToEnd() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
-
- try store.moveTask(taskID: taskIDs[0], toIndex: 999)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[2].id == taskIDs[0])
- }
-
- // MARK: - Completed Task Tests
-
- @Test("Moving only affects active tasks")
- func movingOnlyAffectsActiveTasks() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
- try store.complete(taskID: taskIDs[3])
-
- try store.moveTask(taskID: taskIDs[0], toIndex: 2)
-
- let allTasks = try store.fetchTasks()
- let activeTasks = allTasks.filter { !$0.isCompleted }
- let completedTasks = allTasks.filter { $0.isCompleted }
-
- #expect(activeTasks.count == 3)
- #expect(completedTasks.count == 1)
- #expect(completedTasks[0].id == taskIDs[3])
- }
-
- @Test("Moving completed task does nothing")
- func movingCompletedTaskDoesNothing() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
- try store.complete(taskID: taskIDs[0])
- let originalTasks = try store.fetchTasks()
-
- try store.moveTask(taskID: taskIDs[0], toIndex: 1)
-
- let tasks = try store.fetchTasks()
- #expect(tasks[0].id == originalTasks[0].id)
- #expect(tasks[1].id == originalTasks[1].id)
- #expect(tasks[2].id == originalTasks[2].id)
- }
-
- // MARK: - Edge Cases
-
- @Test("Move single task does nothing")
- func moveSingleTaskDoesNothing() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Only task")
-
- try store.moveTask(taskID: task.id, toIndex: 0)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.count == 1)
- #expect(tasks[0].id == task.id)
- }
-
- @Test("Move in empty store does nothing")
- func moveInEmptyStoreDoesNothing() async throws {
- let store = makeTestStore()
- let randomID = UUID()
-
- try store.moveTask(taskID: randomID, toIndex: 0)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.isEmpty)
- }
-
- @Test("Multiple moves maintain order")
- func multipleMoveMaintainOrder() async throws {
- let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
-
- try store.moveTask(taskID: taskIDs[0], toIndex: 3)
- try store.moveTask(taskID: taskIDs[2], toIndex: 0)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
- #expect(tasks[0].id == taskIDs[2])
- #expect(tasks[3].id == taskIDs[0])
- }
-}
diff --git a/Tests/Unit/TaskStoreTests.swift b/Tests/Unit/TaskStoreTests.swift
@@ -1,208 +0,0 @@
-import Foundation
-import Testing
-
-#if os(macOS)
-@testable import Listless
-#else
-@testable import Listless_iOS
-#endif
-
-@Suite("TaskStore CRUD Operations", .serialized)
-@MainActor
-struct TaskStoreTests {
-
- // MARK: - Creation Tests
-
- @Test("Create task with empty title")
- func createTaskWithEmptyTitle() async throws {
- let store = makeTestStore()
-
- let task = try store.createTask()
-
- #expect(task.title == "")
- #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
- #expect(task.isCompleted == false)
- #expect(task.createdAt.timeIntervalSinceNow > -1.0)
- }
-
- @Test("Create task with title")
- func createTaskWithTitle() async throws {
- let store = makeTestStore()
-
- let task = try store.createTask(title: "Buy groceries")
-
- #expect(task.title == "Buy groceries")
- #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
- }
-
- @Test("Create multiple tasks with unique IDs")
- func createMultipleTasksWithUniqueIDs() async throws {
- let store = makeTestStore()
-
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
- let task3 = try store.createTask(title: "Task 3")
-
- #expect(task1.id != task2.id)
- #expect(task2.id != task3.id)
- #expect(task1.id != task3.id)
- }
-
- @Test("Created task has timestamps")
- func createdTaskHasTimestamps() async throws {
- let store = makeTestStore()
-
- let beforeCreate = Date()
- let task = try store.createTask(title: "Test")
- let afterCreate = Date()
-
- #expect(task.createdAt >= beforeCreate)
- #expect(task.createdAt <= afterCreate)
- #expect(task.updatedAt >= beforeCreate)
- #expect(task.updatedAt <= afterCreate)
- }
-
- @Test("Create task at beginning prepends to active tasks")
- func createTaskAtBeginningPrepends() async throws {
- let store = makeTestStore()
-
- let first = try store.createTask(title: "First")
- let second = try store.createTask(title: "Second")
- let prepended = try store.createTask(title: "Prepended", atBeginning: true)
-
- let tasks = try store.fetchTasks().filter { !$0.isCompleted }
-
- #expect(tasks.map(\.title) == ["Prepended", "First", "Second"])
- #expect(prepended.sortOrder < first.sortOrder)
- #expect(first.sortOrder < second.sortOrder)
- }
-
- // MARK: - Fetch Tests
-
- @Test("Fetch tasks from empty store")
- func fetchTasksFromEmptyStore() async throws {
- let store = makeTestStore()
-
- let tasks = try store.fetchTasks()
-
- #expect(tasks.isEmpty)
- }
-
- @Test("Fetch tasks returns created tasks")
- func fetchTasksReturnsCreatedTasks() async throws {
- let store = makeTestStore()
- _ = try store.createTask(title: "Task 1")
- _ = try store.createTask(title: "Task 2")
-
- let tasks = try store.fetchTasks()
-
- #expect(tasks.count == 2)
- #expect(tasks[0].title == "Task 1")
- #expect(tasks[1].title == "Task 2")
- }
-
- // MARK: - Update Tests
-
- @Test("Update task title")
- func updateTaskTitle() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Original")
-
- try store.update(taskID: task.id, title: "Updated")
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == "Updated")
- }
-
- @Test("Update task title without saving")
- func updateTaskTitleWithoutSaving() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Original")
-
- try store.updateWithoutSaving(taskID: task.id, title: "Updated")
-
- let tasks = try store.fetchTasks()
- #expect(tasks.first?.title == "Updated")
- }
-
- @Test("Update with invalid ID does nothing")
- func updateWithInvalidIDDoesNothing() async throws {
- let store = makeTestStore()
- _ = try store.createTask(title: "Task 1")
- let invalidID = UUID()
-
- try store.update(taskID: invalidID, title: "Should not exist")
-
- let tasks = try store.fetchTasks()
- #expect(tasks.count == 1)
- #expect(tasks.first?.title == "Task 1")
- }
-
- // MARK: - Delete Tests
-
- @Test("Delete task")
- func deleteTask() async throws {
- let store = makeTestStore()
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
-
- try store.delete(taskID: task1.id)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.count == 1)
- #expect(tasks.first?.id == task2.id)
- }
-
- @Test("Delete all tasks")
- func deleteAllTasks() async throws {
- let store = makeTestStore()
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
-
- try store.delete(taskID: task1.id)
- try store.delete(taskID: task2.id)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.isEmpty)
- }
-
- @Test("Delete with invalid ID does nothing")
- func deleteWithInvalidIDDoesNothing() async throws {
- let store = makeTestStore()
- _ = try store.createTask(title: "Task 1")
- let invalidID = UUID()
-
- try store.delete(taskID: invalidID)
-
- let tasks = try store.fetchTasks()
- #expect(tasks.count == 1)
- }
-
- // MARK: - Edge Cases
-
- @Test("Task IDs persist across fetches")
- func taskIDsPersistAcrossFetches() async throws {
- let store = makeTestStore()
- let task = try store.createTask(title: "Test")
- let originalID = task.id
-
- let fetchedTasks = try store.fetchTasks()
- let fetchedID = fetchedTasks.first?.id
-
- #expect(fetchedID == originalID)
- }
-
- @Test("Create task increments sortOrder")
- func createTaskIncrementsSortOrder() async throws {
- let store = makeTestStore()
-
- let task1 = try store.createTask(title: "Task 1")
- let task2 = try store.createTask(title: "Task 2")
- let task3 = try store.createTask(title: "Task 3")
-
- #expect(task2.sortOrder > task1.sortOrder)
- #expect(task3.sortOrder > task2.sortOrder)
- #expect(task2.sortOrder - task1.sortOrder == 1000)
- #expect(task3.sortOrder - task2.sortOrder == 1000)
- }
-}