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
56 files changed, 2251 insertions(+), 2251 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/TaskListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift
@@ -1,41 +1,41 @@
import SwiftUI
-extension TaskListViewProtocol {
+extension ItemListViewProtocol {
// MARK: - Computed Properties
- var activeTasks: [TaskItem] {
- Array(tasks.filter { !$0.isDeleted && !$0.isCompleted })
+ var activeItems: [ItemEntity] {
+ Array(items.filter { !$0.isDeleted && !$0.isCompleted })
.sorted { $0.sortOrder < $1.sortOrder }
}
- var displayActiveTasks: [TaskItem] {
+ var displayActiveItems: [ItemEntity] {
guard let visualOrder else {
- return activeTasks
+ return activeItems
}
return visualOrder.compactMap { id in
- activeTasks.first(where: { $0.id == id })
+ activeItems.first(where: { $0.id == id })
}
}
- var completedTasks: [TaskItem] {
- Array(tasks.filter { !$0.isDeleted && $0.isCompleted })
+ var completedItems: [ItemEntity] {
+ Array(items.filter { !$0.isDeleted && $0.isCompleted })
.sorted { $0.completedOrder > $1.completedOrder }
}
- var allTasksInDisplayOrder: [TaskItem] {
- displayActiveTasks + completedTasks
+ var allItemsInDisplayOrder: [ItemEntity] {
+ displayActiveItems + completedItems
}
- var editingTaskID: UUID? {
- if case .task(let id) = focusedField {
+ var editingItemID: UUID? {
+ if case .item(let id) = focusedField {
return id
}
return nil
}
- var draggedTaskID: UUID? {
+ var draggedItemID: UUID? {
if case .dragging(let id, _) = dragState {
return id
}
@@ -53,12 +53,12 @@ extension TaskListViewProtocol {
syncMonitor.ingest(error: error)
}
- private func isLastActiveTask(_ taskID: UUID) -> Bool {
- guard let lastTask = activeTasks.last else { return false }
- return lastTask.id == taskID
+ private func isLastActiveItem(_ itemID: UUID) -> Bool {
+ guard let lastItem = activeItems.last else { return false }
+ return lastItem.id == itemID
}
- func draftID(for placement: DraftTaskPlacement) -> UUID {
+ func draftID(for placement: DraftItemPlacement) -> UUID {
switch placement {
case .prepend:
draftPrependRowID
@@ -67,8 +67,8 @@ extension TaskListViewProtocol {
}
}
- func draftPlacement(for taskID: UUID) -> DraftTaskPlacement? {
- switch taskID {
+ func draftPlacement(for itemID: UUID) -> DraftItemPlacement? {
+ switch itemID {
case draftPrependRowID:
.prepend
case draftAppendRowID:
@@ -78,134 +78,134 @@ extension TaskListViewProtocol {
}
}
- // MARK: - Task Creation
+ // MARK: - Item Creation
- func createNewTaskAtTop() -> UUID {
- revealDraftTask(at: .prepend)
+ func createNewItemAtTop() -> UUID {
+ revealDraftItem(at: .prepend)
return draftPrependRowID
}
- func createNewTask() {
- revealDraftTask(at: .append)
+ func createNewItem() {
+ revealDraftItem(at: .append)
}
- func revealDraftTask(at placement: DraftTaskPlacement) {
+ func revealDraftItem(at placement: DraftItemPlacement) {
if draftPlacement != placement, draftPlacement != nil {
- commitDraftTask()
+ commitDraftItem()
}
clearDragState()
- let taskID = draftID(for: placement)
+ let itemID = draftID(for: placement)
draftTitle = ""
draftPlacement = placement
- fState.pendingFocus = .task(taskID)
- focusedField = .task(taskID)
- fState.selectedTaskID = taskID
+ fState.pendingFocus = .item(itemID)
+ focusedField = .item(itemID)
+ fState.selectedItemID = itemID
}
- func beginDraftTaskEditing(_ placement: DraftTaskPlacement) {
+ func beginDraftItemEditing(_ placement: DraftItemPlacement) {
guard draftPlacement == placement else { return }
- let taskID = draftID(for: placement)
- fState.selectedTaskID = taskID
- if case .task(let id) = fState.pendingFocus, id == taskID {
+ let itemID = draftID(for: placement)
+ fState.selectedItemID = itemID
+ if case .item(let id) = fState.pendingFocus, id == itemID {
fState.pendingFocus = nil
}
}
- func commitDraftTask(shouldCreateNewTask: Bool = false) {
+ func commitDraftItem(shouldCreateNewItem: Bool = false) {
guard let placement = draftPlacement else { return }
- let taskID = draftID(for: placement)
+ let itemID = draftID(for: placement)
let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
- // Clear fState.pendingFocus before clearDraftTaskUI so that the iOS
+ // 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 .task(let id) = fState.pendingFocus, id == taskID {
+ if case .item(let id) = fState.pendingFocus, id == itemID {
fState.pendingFocus = nil
}
- clearDraftTaskUI(at: placement, hasTitle: !title.isEmpty)
+ clearDraftItemUI(at: placement, hasTitle: !title.isEmpty)
- if fState.selectedTaskID == taskID {
- fState.selectedTaskID = nil
+ if fState.selectedItemID == itemID {
+ fState.selectedItemID = nil
}
guard !title.isEmpty else { return }
do {
- let task = switch placement {
+ let item = switch placement {
case .prepend:
- try store.createTask(title: title, atBeginning: true)
+ try store.createItem(title: title, atBeginning: true)
case .append:
- try store.createTask(title: title)
+ try store.createItem(title: title)
}
try store.save()
if placement == .append {
- fState.selectedTaskID = task.id
+ fState.selectedItemID = item.id
}
} catch {
presentStoreError(error)
}
- if shouldCreateNewTask, placement == .append {
- revealDraftTask(at: .append)
+ if shouldCreateNewItem, placement == .append {
+ revealDraftItem(at: .append)
}
}
- func createTask(title: String, afterTaskID: UUID) {
+ func createItem(title: String, afterItemID: UUID) {
clearDragState()
do {
- let sortOrder = try sortOrderAfter(taskID: afterTaskID)
- let newTask = try store.createTask(title: title, sortOrder: sortOrder)
+ let sortOrder = try sortOrderAfter(itemID: afterItemID)
+ let newItem = try store.createItem(title: title, sortOrder: sortOrder)
try store.save()
- fState.selectedTaskID = newTask.id
+ fState.selectedItemID = newItem.id
focusedField = .scrollView
} catch {
presentStoreError(error)
}
}
- private func sortOrderAfter(taskID: UUID) throws -> Int64? {
- guard let afterIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else {
+ private func sortOrderAfter(itemID: UUID) throws -> Int64? {
+ guard let afterIndex = activeItems.firstIndex(where: { $0.id == itemID }) 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 {
+ 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 afterTask/nextTask reflect
+ // then recompute. Core Data's identity map ensures afterItem/nextItem reflect
// the updated values immediately after normalisation.
try store.normalizeSortOrders()
- return (afterTask.sortOrder + nextTask.sortOrder) / 2
+ return (afterItem.sortOrder + nextItem.sortOrder) / 2
}
return midpoint
} else {
- return afterTask.sortOrder + 1000
+ return afterItem.sortOrder + 1000
}
}
// MARK: - Interaction Handlers
func handleBackgroundTap() {
- let isTaskFocused = if case .task = focusedField { true } else { false }
+ let isItemFocused = if case .item = focusedField { true } else { false }
- if isTaskFocused || fState.selectedTaskID != nil {
+ if isItemFocused || fState.selectedItemID != nil {
fState.pendingFocus = nil
if draftPlacement != nil {
- commitDraftTask()
+ commitDraftItem()
}
- fState.selectedTaskID = nil
+ fState.selectedItemID = nil
focusedField = nil
} else {
- revealDraftTask(at: .append)
+ revealDraftItem(at: .append)
}
}
func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
- let oldID = taskID(from: oldValue)
- let newID = taskID(from: newValue)
+ let oldID = itemID(from: oldValue)
+ let newID = itemID(from: newValue)
guard oldID != newID, let oldID else {
return
@@ -215,102 +215,102 @@ extension TaskListViewProtocol {
return
}
- deleteIfEmpty(taskID: oldID)
+ deleteIfEmpty(itemID: oldID)
}
- private func taskID(from field: FocusField?) -> UUID? {
- guard case .task(let id) = field else { return nil }
+ private func itemID(from field: FocusField?) -> UUID? {
+ guard case .item(let id) = field else { return nil }
return id
}
- private func deleteIfEmpty(taskID: UUID) {
- if case .task(let pendingTaskID) = fState.pendingFocus, pendingTaskID == taskID {
+ private func deleteIfEmpty(itemID: UUID) {
+ if case .item(let pendingItemID) = fState.pendingFocus, pendingItemID == itemID {
return
}
- guard let task = tasks.first(where: { $0.id == taskID }) else {
+ guard let item = items.first(where: { $0.id == itemID }) else {
return
}
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmedTitle.isEmpty else { return }
- managedObjectContext.undoManager?.removeAllActions(withTarget: task)
+ managedObjectContext.undoManager?.removeAllActions(withTarget: item)
managedObjectContext.undoManager?.disableUndoRegistration()
- deleteTask(task)
+ deleteItem(item)
managedObjectContext.undoManager?.enableUndoRegistration()
}
- func updateTitle(_ task: TaskItem, _ title: String) {
- guard task.title != title else { return }
+ func updateTitle(_ item: ItemEntity, _ title: String) {
+ guard item.title != title else { return }
do {
- try store.updateWithoutSaving(taskID: task.id, title: title)
+ try store.updateWithoutSaving(itemID: item.id, title: title)
} catch {
presentStoreError(error)
}
}
- func toggleCompletion(_ task: TaskItem) {
+ func toggleCompletion(_ item: ItemEntity) {
do {
- if task.isCompleted {
- try store.uncomplete(taskID: task.id)
+ if item.isCompleted {
+ try store.uncomplete(itemID: item.id)
} else {
- try store.complete(taskID: task.id)
+ try store.complete(itemID: item.id)
}
} catch {
presentStoreError(error)
}
}
- func handleSwipeComplete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- toggleCompletion(task)
+ func handleSwipeComplete(_ itemID: UUID) {
+ guard let item = items.first(where: { $0.id == itemID }) else { return }
+ toggleCompletion(item)
}
- func handleSwipeDelete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- deleteTask(task)
+ func handleSwipeDelete(_ itemID: UUID) {
+ guard let item = items.first(where: { $0.id == itemID }) else { return }
+ deleteItem(item)
}
- func selectTask(
- _ taskID: UUID,
+ func selectItem(
+ _ itemID: UUID,
extendSelection: Bool = false,
toggleSelection: Bool = false
) {
if toggleSelection {
fState.toggleSelection(
- taskID: taskID,
- displayOrder: allTasksInDisplayOrder.map(\.id)
+ itemID: itemID,
+ displayOrder: allItemsInDisplayOrder.map(\.id)
)
- } else if extendSelection && fState.selectedTaskID != nil {
- if fState.anchorTaskID == nil {
- fState.anchorTaskID = fState.cursorTaskID
+ } else if extendSelection && fState.selectedItemID != nil {
+ if fState.anchorItemID == nil {
+ fState.anchorItemID = fState.cursorItemID
}
fState.extendSelection(
- to: taskID,
- displayOrder: allTasksInDisplayOrder.map(\.id)
+ to: itemID,
+ displayOrder: allItemsInDisplayOrder.map(\.id)
)
} else {
- fState.selectedTaskID = taskID
+ fState.selectedItemID = itemID
}
}
- func deleteTask(_ task: TaskItem) {
- guard !task.isDeleted else { return }
- let taskID = task.id
+ func deleteItem(_ item: ItemEntity) {
+ guard !item.isDeleted else { return }
+ let itemID = item.id
do {
- try store.delete(taskID: taskID)
- if fState.selectedTaskID == taskID {
- fState.selectedTaskID = nil
+ try store.delete(itemID: itemID)
+ if fState.selectedItemID == itemID {
+ fState.selectedItemID = nil
}
} catch {
presentStoreError(error)
}
}
- func clearCompletedTasks() {
- for task in completedTasks.reversed() {
+ func clearCompletedItems() {
+ for item in completedItems.reversed() {
do {
- try store.delete(taskID: task.id)
+ try store.delete(itemID: item.id)
} catch {
presentStoreError(error)
}
@@ -324,18 +324,18 @@ extension TaskListViewProtocol {
return .ignored
}
- guard let currentID = fState.selectedTaskID else {
- fState.selectedTaskID = activeTasks.last?.id
+ guard let currentID = fState.selectedItemID else {
+ fState.selectedItemID = activeItems.last?.id
return .handled
}
- let displayOrder = allTasksInDisplayOrder
+ let displayOrder = allItemsInDisplayOrder
guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
return .handled
}
if currentIndex > 0 {
- fState.selectedTaskID = displayOrder[currentIndex - 1].id
+ fState.selectedItemID = displayOrder[currentIndex - 1].id
}
return .handled
}
@@ -345,18 +345,18 @@ extension TaskListViewProtocol {
return .ignored
}
- guard let currentID = fState.selectedTaskID else {
- fState.selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
+ guard let currentID = fState.selectedItemID else {
+ fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
return .handled
}
- let displayOrder = allTasksInDisplayOrder
+ let displayOrder = allItemsInDisplayOrder
guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
return .handled
}
if currentIndex < displayOrder.count - 1 {
- fState.selectedTaskID = displayOrder[currentIndex + 1].id
+ fState.selectedItemID = displayOrder[currentIndex + 1].id
}
return .handled
}
@@ -366,11 +366,11 @@ extension TaskListViewProtocol {
return .ignored
}
- let displayOrder = allTasksInDisplayOrder.map(\.id)
+ let displayOrder = allItemsInDisplayOrder.map(\.id)
// If nothing is selected yet, start single-select at the bottom.
- guard let cursorID = fState.cursorTaskID else {
- fState.selectedTaskID = activeTasks.last?.id
+ guard let cursorID = fState.cursorItemID else {
+ fState.selectedItemID = activeItems.last?.id
return .handled
}
@@ -383,7 +383,7 @@ extension TaskListViewProtocol {
let targetID = displayOrder[cursorIndex - 1]
// On the first extend, the anchor is wherever the cursor is.
if !fState.hasMultipleSelection {
- fState.anchorTaskID = cursorID
+ fState.anchorItemID = cursorID
}
fState.extendSelection(to: targetID, displayOrder: displayOrder)
return .handled
@@ -394,10 +394,10 @@ extension TaskListViewProtocol {
return .ignored
}
- let displayOrder = allTasksInDisplayOrder.map(\.id)
+ let displayOrder = allItemsInDisplayOrder.map(\.id)
- guard let cursorID = fState.cursorTaskID else {
- fState.selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
+ guard let cursorID = fState.cursorItemID else {
+ fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
return .handled
}
@@ -409,116 +409,116 @@ extension TaskListViewProtocol {
let targetID = displayOrder[cursorIndex + 1]
if !fState.hasMultipleSelection {
- fState.anchorTaskID = cursorID
+ fState.anchorItemID = cursorID
}
fState.extendSelection(to: targetID, displayOrder: displayOrder)
return .handled
}
- func toggleSelectedTask() -> KeyPress.Result {
+ func toggleSelectedItem() -> KeyPress.Result {
guard focusedField == .scrollView else { return .ignored }
- let ids = fState.selectedTaskIDs
+ let ids = fState.selectedItemIDs
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 }
+ 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 task in tasksToToggle {
- toggleCompletion(task)
+ for item in itemsToToggle {
+ toggleCompletion(item)
}
return .handled
}
- func focusSelectedTask() -> KeyPress.Result {
+ func focusSelectedItem() -> 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 {
+ guard let currentID = fState.selectedItemID else { return .handled }
+ guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
return .handled
}
- guard !task.isCompleted else { return .handled }
+ guard !item.isCompleted else { return .handled }
startEditing(currentID)
return .handled
}
- func deleteSelectedTask() -> KeyPress.Result {
+ func deleteSelectedItem() -> KeyPress.Result {
guard focusedField == .scrollView else {
return .ignored
}
- let ids = fState.selectedTaskIDs
+ let ids = fState.selectedItemIDs
guard !ids.isEmpty else { return .handled }
- let displayOrder = allTasksInDisplayOrder
- let tasksToDelete = displayOrder.filter { ids.contains($0.id) }
- guard !tasksToDelete.isEmpty else { return .handled }
+ let displayOrder = allItemsInDisplayOrder
+ let itemsToDelete = displayOrder.filter { ids.contains($0.id) }
+ guard !itemsToDelete.isEmpty else { return .handled }
- // Find the next task after the last selected one to move selection to.
+ // Find the next item after the last selected one to move selection to.
let lastSelectedIndex = displayOrder.lastIndex(where: { ids.contains($0.id) })
- let nextTask = lastSelectedIndex.flatMap { idx in
+ let nextItem = lastSelectedIndex.flatMap { idx in
displayOrder.dropFirst(idx + 1).first(where: { !ids.contains($0.id) })
}
- fState.selectedTaskID = nil
- for task in tasksToDelete {
- deleteTask(task)
+ fState.selectedItemID = nil
+ for item in itemsToDelete {
+ deleteItem(item)
}
- if let nextTask {
- fState.selectedTaskID = nextTask.id
+ if let nextItem {
+ fState.selectedItemID = nextItem.id
}
return .handled
}
- func moveSelectedTaskUp() {
+ func moveSelectedItemUp() {
guard focusedField == .scrollView else { return }
- guard let currentID = fState.selectedTaskID else { return }
- guard let currentIndex = activeTasks.firstIndex(where: { $0.id == currentID }) 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.moveTask(taskID: currentID, toIndex: currentIndex - 1)
+ try store.moveItem(itemID: currentID, toIndex: currentIndex - 1)
} catch {
presentStoreError(error)
}
}
- func moveSelectedTaskDown() {
+ func moveSelectedItemDown() {
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 }
+ 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.moveTask(taskID: currentID, toIndex: currentIndex + 1)
+ try store.moveItem(itemID: currentID, toIndex: currentIndex + 1)
} catch {
presentStoreError(error)
}
}
- func markSelectedTaskCompleted() {
+ func markSelectedItemCompleted() {
guard focusedField == .scrollView else { return }
- let ids = fState.selectedTaskIDs
+ let ids = fState.selectedItemIDs
guard !ids.isEmpty else { return }
- let tasksToToggle = allTasksInDisplayOrder.filter { ids.contains($0.id) }
- for task in tasksToToggle {
- toggleCompletion(task)
+ let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
+ for item in itemsToToggle {
+ toggleCompletion(item)
}
}
// MARK: - Focus Management
- func focusTextField(_ taskID: UUID) {
- focusedField = .task(taskID)
+ func focusTextField(_ itemID: UUID) {
+ focusedField = .item(itemID)
}
- func startEditing(_ taskID: UUID) {
- fState.selectedTaskID = taskID
- focusedField = .task(taskID)
+ func startEditing(_ itemID: UUID) {
+ fState.selectedItemID = itemID
+ focusedField = .item(itemID)
fState.pendingFocus = nil
}
- func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) {
- if draftPlacement(for: taskID) != nil {
- commitDraftTask(shouldCreateNewTask: shouldCreateNewTask)
+ func endEditing(_ itemID: UUID, shouldCreateNewItem: Bool) {
+ if draftPlacement(for: itemID) != nil {
+ commitDraftItem(shouldCreateNewItem: shouldCreateNewItem)
return
}
@@ -528,39 +528,39 @@ extension TaskListViewProtocol {
presentStoreError(error)
}
- let wasLastActiveTask = isLastActiveTask(taskID)
- let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
+ let wasLastActiveItem = isLastActiveItem(itemID)
+ let willBeDeleted = shouldDeleteIfEmpty(itemID: itemID)
if willBeDeleted {
- fState.selectedTaskID = nil
- deleteIfEmpty(taskID: taskID)
- } else if wasLastActiveTask && shouldCreateNewTask {
- revealDraftTask(at: .append)
- } else if shouldCreateNewTask {
+ fState.selectedItemID = nil
+ deleteIfEmpty(itemID: itemID)
+ } else if wasLastActiveItem && shouldCreateNewItem {
+ revealDraftItem(at: .append)
+ } else if shouldCreateNewItem {
focusedField = .scrollView
}
}
- private func shouldDeleteIfEmpty(taskID: UUID) -> Bool {
- guard let task = tasks.first(where: { $0.id == taskID }) else {
+ private func shouldDeleteIfEmpty(itemID: UUID) -> Bool {
+ guard let item = items.first(where: { $0.id == itemID }) else {
return false
}
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedTitle.isEmpty
}
// MARK: - Drag and Drop
- func startDrag(taskID: UUID) {
+ func startDrag(itemID: UUID) {
guard case .idle = dragState else { return }
withAnimation(.easeOut(duration: 0.15)) {
- dragState = .dragging(id: taskID, order: activeTasks.map(\.id))
+ dragState = .dragging(id: itemID, order: activeItems.map(\.id))
}
didStartDrag()
}
func updateVisualOrder(insertBefore targetID: UUID) {
- guard let draggedID = draggedTaskID,
+ guard let draggedID = draggedItemID,
let order = visualOrder
else { return }
@@ -577,7 +577,7 @@ extension TaskListViewProtocol {
}
func updateVisualOrder(insertAfter targetID: UUID) {
- guard let draggedID = draggedTaskID,
+ guard let draggedID = draggedItemID,
let order = visualOrder
else { return }
@@ -594,7 +594,7 @@ extension TaskListViewProtocol {
}
func updateVisualOrderSmart(relativeTo targetID: UUID) {
- guard let draggedID = draggedTaskID,
+ guard let draggedID = draggedItemID,
let order = visualOrder
else { return }
@@ -610,7 +610,7 @@ extension TaskListViewProtocol {
}
func updateVisualOrder(insertAtEnd: Bool) {
- guard let draggedID = draggedTaskID,
+ guard let draggedID = draggedItemID,
let order = visualOrder
else { return }
@@ -625,7 +625,7 @@ extension TaskListViewProtocol {
}
func commitCurrentDrag() -> Bool {
- guard let droppedUUID = draggedTaskID,
+ guard let droppedUUID = draggedItemID,
let order = visualOrder,
let finalIndex = order.firstIndex(of: droppedUUID)
else {
@@ -634,7 +634,7 @@ extension TaskListViewProtocol {
}
do {
- try store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+ try store.moveItem(itemID: droppedUUID, toIndex: finalIndex)
clearDragState()
} catch {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
diff --git a/Listless/Extensions/TaskListView+SyncUI.swift b/Listless/Extensions/ItemListView+SyncUI.swift
@@ -5,7 +5,7 @@ import UIKit
import AppKit
#endif
-extension TaskListViewProtocol {
+extension ItemListViewProtocol {
@ViewBuilder
var syncErrorBanner: some View {
if let message = syncMonitor.transientErrorMessage {
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/TaskListTypes.swift b/Listless/Helpers/ItemListTypes.swift
@@ -1,7 +1,7 @@
import Foundation
enum FocusField: Hashable {
- case task(UUID)
+ case item(UUID)
case scrollView
}
@@ -10,7 +10,7 @@ enum DragState: Equatable {
case dragging(id: UUID, order: [UUID])
}
-enum DraftTaskPlacement: Equatable {
+enum DraftItemPlacement: Equatable {
case prepend
case append
}
@@ -21,16 +21,16 @@ struct FocusStateData {
// MARK: - Selection
- /// The full set of selected task IDs (supports multi-select on macOS).
- private(set) var selectedTaskIDs: Set<UUID> = []
+ /// 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 anchorTaskID: UUID?
+ 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 cursorTaskID: UUID?
+ private(set) var cursorItemID: UUID?
/// Selected items outside the active anchor–cursor range, preserved
/// across Shift+Arrow operations after a Cmd+Click toggle.
@@ -40,31 +40,31 @@ struct FocusStateData {
/// 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 }
+ var selectedItemID: UUID? {
+ get { cursorItemID }
set {
- anchorTaskID = newValue
- cursorTaskID = newValue
- selectedTaskIDs = newValue.map { Set([$0]) } ?? []
+ anchorItemID = newValue
+ cursorItemID = newValue
+ selectedItemIDs = newValue.map { Set([$0]) } ?? []
inactiveSelections = []
}
}
- func isTaskSelected(_ id: UUID) -> Bool {
- selectedTaskIDs.contains(id)
+ func isItemSelected(_ id: UUID) -> Bool {
+ selectedItemIDs.contains(id)
}
var hasMultipleSelection: Bool {
- selectedTaskIDs.count > 1
+ selectedItemIDs.count > 1
}
- /// Select all tasks in display order, anchoring at the first and
+ /// 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 }
- anchorTaskID = displayOrder.first
- cursorTaskID = displayOrder.last
- selectedTaskIDs = Set(displayOrder)
+ anchorItemID = displayOrder.first
+ cursorItemID = displayOrder.last
+ selectedItemIDs = Set(displayOrder)
inactiveSelections = []
}
@@ -74,25 +74,25 @@ struct FocusStateData {
/// 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 }
+ mutating func toggleSelection(itemID: UUID, displayOrder: [UUID]) {
+ guard let toggledIndex = displayOrder.firstIndex(of: itemID) else { return }
- let wasSelected = selectedTaskIDs.contains(taskID)
+ let wasSelected = selectedItemIDs.contains(itemID)
if wasSelected {
- selectedTaskIDs.remove(taskID)
+ selectedItemIDs.remove(itemID)
} else {
- selectedTaskIDs.insert(taskID)
+ selectedItemIDs.insert(itemID)
}
- guard !selectedTaskIDs.isEmpty else {
- anchorTaskID = nil
- cursorTaskID = nil
+ guard !selectedItemIDs.isEmpty else {
+ anchorItemID = nil
+ cursorItemID = nil
inactiveSelections = []
return
}
// Anchor = item below the toggled item (or self if at bottom).
- anchorTaskID =
+ anchorItemID =
toggledIndex + 1 < displayOrder.count
? displayOrder[toggledIndex + 1]
: displayOrder[toggledIndex]
@@ -100,24 +100,24 @@ struct FocusStateData {
if wasSelected {
// Deselecting: cursor stays so Shift+Arrow contracts from
// the far end of the remaining selection.
- if cursorTaskID == nil {
- cursorTaskID = anchorTaskID
+ if cursorItemID == nil {
+ cursorItemID = anchorItemID
}
} else {
// Adding: cursor resets to anchor so the active range is
// small, preserving other selections as inactive.
- cursorTaskID = anchorTaskID
+ cursorItemID = anchorItemID
}
recomputeInactiveSelections(displayOrder: displayOrder)
}
/// Extend or contract the selection from the anchor to `targetID`,
- /// selecting all tasks between them in `displayOrder`. Inactive
+ /// 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 = anchorTaskID,
+ guard let anchorID = anchorItemID,
let anchorIndex = displayOrder.firstIndex(of: anchorID),
let targetIndex = displayOrder.firstIndex(of: targetID)
else {
@@ -126,17 +126,17 @@ struct FocusStateData {
let lo = min(anchorIndex, targetIndex)
let hi = max(anchorIndex, targetIndex)
let activeRange = Set(displayOrder[lo...hi])
- selectedTaskIDs = inactiveSelections.union(activeRange)
- cursorTaskID = targetID
+ selectedItemIDs = inactiveSelections.union(activeRange)
+ cursorItemID = targetID
mergeAdjacentInactiveSelections(displayOrder: displayOrder)
}
// MARK: - Private Helpers
- /// Partition `selectedTaskIDs` into those inside vs outside the
+ /// Partition `selectedItemIDs` into those inside vs outside the
/// anchor–cursor range.
private mutating func recomputeInactiveSelections(displayOrder: [UUID]) {
- guard let anchorID = anchorTaskID, let cursorID = cursorTaskID,
+ guard let anchorID = anchorItemID, let cursorID = cursorItemID,
let anchorIndex = displayOrder.firstIndex(of: anchorID),
let cursorIndex = displayOrder.firstIndex(of: cursorID)
else {
@@ -146,7 +146,7 @@ struct FocusStateData {
let lo = min(anchorIndex, cursorIndex)
let hi = max(anchorIndex, cursorIndex)
let activeRange = Set(displayOrder[lo...hi])
- inactiveSelections = selectedTaskIDs.subtracting(activeRange)
+ inactiveSelections = selectedItemIDs.subtracting(activeRange)
}
/// When the active range becomes adjacent to inactive selections,
@@ -155,9 +155,9 @@ struct FocusStateData {
/// from the anchor).
private mutating func mergeAdjacentInactiveSelections(displayOrder: [UUID]) {
guard !inactiveSelections.isEmpty,
- let anchorID = anchorTaskID,
+ let anchorID = anchorItemID,
let anchorIndex = displayOrder.firstIndex(of: anchorID),
- let cursorID = cursorTaskID,
+ let cursorID = cursorItemID,
let cursorIndex = displayOrder.firstIndex(of: cursorID)
else {
return
@@ -186,12 +186,12 @@ struct FocusStateData {
inactiveSelections.subtract(mergedIDs)
if cursorIndex <= anchorIndex {
- cursorTaskID = displayOrder[lo]
+ cursorItemID = displayOrder[lo]
} else {
- cursorTaskID = displayOrder[hi]
+ cursorItemID = displayOrder[hi]
}
- selectedTaskIDs = inactiveSelections.union(Set(displayOrder[lo...hi]))
+ selectedItemIDs = inactiveSelections.union(Set(displayOrder[lo...hi]))
}
}
diff --git a/Listless/Helpers/TaskListViewProtocol.swift b/Listless/Helpers/ItemListViewProtocol.swift
@@ -2,16 +2,16 @@ import CoreData
import SwiftUI
@MainActor
-protocol TaskListViewProtocol {
- var tasks: FetchedResults<TaskItem> { get }
- var store: TaskStore { get }
+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: DraftTaskPlacement? { get nonmutating set }
+ var draftPlacement: DraftItemPlacement? { get nonmutating set }
var draftTitle: String { get nonmutating set }
func didStartDrag()
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool)
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool)
}
diff --git a/Listless/Models/TaskItem.swift b/Listless/Models/ItemEntity.swift
@@ -2,18 +2,18 @@ import CoreData
import Foundation
@objc(TaskItem)
-public class TaskItem: NSManagedObject, Identifiable {
+public class ItemEntity: NSManagedObject, Identifiable {
private enum Keys {
- private static func key<Value>(_ keyPath: KeyPath<TaskItem, Value>) -> String {
+ private static func key<Value>(_ keyPath: KeyPath<ItemEntity, 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)
+ 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
@@ -25,8 +25,8 @@ public class TaskItem: NSManagedObject, Identifiable {
public var isCompleted: Bool { completedOrder > 0 }
- @nonobjc public class func fetchRequest() -> NSFetchRequest<TaskItem> {
- return NSFetchRequest<TaskItem>(entityName: "TaskItem")
+ @nonobjc public class func fetchRequest() -> NSFetchRequest<ItemEntity> {
+ return NSFetchRequest<ItemEntity>(entityName: "TaskItem")
}
public override func awakeFromInsert() {
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/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/TaskListView+Toolbar.swift b/ListlessMac/Extensions/ItemListView+Toolbar.swift
@@ -1,6 +1,6 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
@ToolbarContentBuilder
var platformToolbar: some ToolbarContent {
ToolbarItem(placement: .automatic) {
@@ -24,14 +24,14 @@ extension TaskListView {
}
Button {
- createNewTask()
+ createNewItem()
} label: {
Label("New Item", systemImage: "plus")
}
.help("Create a new item")
Button {
- _ = deleteSelectedTask()
+ _ = deleteSelectedItem()
} label: {
Label("Delete", systemImage: "trash")
}
@@ -41,12 +41,12 @@ extension TaskListView {
Divider()
Button {
- clearCompletedTasks()
+ clearCompletedItems()
} label: {
Label("Clear Completed", systemImage: "tray")
}
- .disabled(completedTasks.isEmpty)
- .help("Clear all completed tasks")
+ .disabled(completedItems.isEmpty)
+ .help("Clear all completed items")
}
}
}
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/TaskRowDragGesture.swift b/ListlessMac/Helpers/ItemRowDragGesture.swift
@@ -3,17 +3,17 @@ import SwiftUI
import UniformTypeIdentifiers
extension View {
- func taskDragGesture(
+ func itemDragGesture(
isActive: Bool,
- taskID: UUID,
+ itemID: UUID,
onDragStart: @escaping () -> Void,
onLift: @escaping () -> Void = {},
onLiftEnd: @escaping () -> Void = {}
) -> some View {
self.modifier(
- TaskRowDragGesture(
+ ItemRowDragGesture(
isActive: isActive,
- taskID: taskID,
+ itemID: itemID,
onDragStart: onDragStart,
onLift: onLift,
onLiftEnd: onLiftEnd
@@ -21,9 +21,9 @@ extension View {
}
}
-struct TaskRowDragGesture: ViewModifier {
+struct ItemRowDragGesture: ViewModifier {
let isActive: Bool
- let taskID: UUID
+ let itemID: UUID
let onDragStart: () -> Void
let onLift: () -> Void
let onLiftEnd: () -> Void
@@ -61,7 +61,7 @@ struct TaskRowDragGesture: ViewModifier {
NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
if isLifted && !dragSource.isActive {
onDragStart()
- dragSource.beginDrag(taskID: taskID, event: event)
+ dragSource.beginDrag(itemID: itemID, event: event)
}
return event
}!
@@ -113,9 +113,9 @@ class DragSourceManager: NSObject, NSDraggingSource {
var isActive = false
var onDragEnd: (() -> Void)?
- func beginDrag(taskID: UUID, event: NSEvent) {
+ func beginDrag(itemID: UUID, event: NSEvent) {
guard let sourceView, !isActive else { return }
- let item = NSDraggingItem(pasteboardWriter: taskID.uuidString as NSString)
+ 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
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/TaskListView.swift b/ListlessMac/Views/ItemListView.swift
@@ -1,11 +1,11 @@
import SwiftUI
import UniformTypeIdentifiers
-struct TaskListView: View, TaskListViewProtocol {
+struct ItemListView: View, ItemListViewProtocol {
struct InteractionStateData {
var dragState: DragState = .idle
- var liftedTaskID: UUID?
- var draftPlacement: DraftTaskPlacement?
+ var liftedItemID: UUID?
+ var draftPlacement: DraftItemPlacement?
var draftTitle: String = ""
}
@@ -15,14 +15,14 @@ struct TaskListView: View, TaskListViewProtocol {
@Environment(\.undoManager) var undoManager
@Environment(\.managedObjectContext) var managedObjectContext
- let store: TaskStore
+ let store: ItemStore
let windowCoordinator: WindowCoordinator
@ObservedObject var syncMonitor: CloudKitSyncMonitor
@FetchRequest(
sortDescriptors: [],
animation: .default
)
- var tasks: FetchedResults<TaskItem>
+ var items: FetchedResults<ItemEntity>
@FocusState private var focusedFieldBinding: FocusField?
@State var fState = FocusStateData()
@State private var iState = InteractionStateData()
@@ -40,7 +40,7 @@ struct TaskListView: View, TaskListViewProtocol {
nonmutating set { iState.dragState = newValue }
}
- var draftPlacement: DraftTaskPlacement? {
+ var draftPlacement: DraftItemPlacement? {
get { iState.draftPlacement }
nonmutating set { iState.draftPlacement = newValue }
}
@@ -51,19 +51,19 @@ struct TaskListView: View, TaskListViewProtocol {
}
var vStackSpacing: CGFloat { 0 }
- var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
+ var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
var selectedIndex: Int? {
- guard let currentID = fState.selectedTaskID else { return nil }
- return activeTasks.firstIndex(where: { $0.id == currentID })
+ guard let currentID = fState.selectedItemID else { return nil }
+ return activeItems.firstIndex(where: { $0.id == currentID })
}
var canDeleteSelectionFromList: Bool {
- !fState.selectedTaskIDs.isEmpty && focusedField == .scrollView
+ !fState.selectedItemIDs.isEmpty && focusedField == .scrollView
}
var canMarkSelectionCompleted: Bool {
guard focusedField == .scrollView else { return false }
- let selected = allTasksInDisplayOrder.filter { fState.isTaskSelected($0.id) }
+ 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 }
@@ -72,10 +72,10 @@ struct TaskListView: View, TaskListViewProtocol {
var markCompletedMenuTitle: String {
if fState.hasMultipleSelection {
- let hasCompleted = completedTasks.contains(where: { fState.isTaskSelected($0.id) })
+ let hasCompleted = completedItems.contains(where: { fState.isItemSelected($0.id) })
return hasCompleted ? "Mark as Incomplete" : "Mark as Complete"
}
- return completedTasks.contains(where: { $0.id == fState.selectedTaskID })
+ return completedItems.contains(where: { $0.id == fState.selectedItemID })
? "Mark as Incomplete" : "Mark as Complete"
}
@@ -90,89 +90,89 @@ struct TaskListView: View, TaskListViewProtocol {
guard focusedField == .scrollView else { return false }
guard !fState.hasMultipleSelection else { return false }
guard let index = selectedIndex else { return false }
- return index < activeTasks.count - 1
+ return index < activeItems.count - 1
}
struct MenuState: Equatable {
- let selectedTaskIDs: Set<UUID>
+ let selectedItemIDs: Set<UUID>
let isScrollViewFocused: Bool
- let activeTaskCount: Int
- let completedTaskCount: Int
+ let activeItemCount: Int
+ let completedItemCount: Int
let selectedIndex: Int?
}
var windowCoordinatorTrigger: MenuState {
MenuState(
- selectedTaskIDs: fState.selectedTaskIDs,
+ selectedItemIDs: fState.selectedItemIDs,
isScrollViewFocused: focusedField == .scrollView,
- activeTaskCount: activeTasks.count,
- completedTaskCount: completedTasks.count,
+ activeItemCount: activeItems.count,
+ completedItemCount: completedItems.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 }
+ 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(task.title, forType: .string)
+ pasteboard.setString(item.title, forType: .string)
}
- coord.cutSelectedTask = {
- guard let taskID = fState.selectedTaskID,
- let task = tasks.first(where: { $0.id == taskID }) else { return }
+ 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(task.title, forType: .string)
- deleteTask(task)
+ pasteboard.setString(item.title, forType: .string)
+ deleteItem(item)
}
- coord.pasteAfterSelectedTask = {
- guard let taskID = fState.selectedTaskID,
+ coord.pasteAfterSelectedItem = {
+ guard let itemID = fState.selectedItemID,
let string = NSPasteboard.general.string(forType: .string) else { return }
- createTask(title: string, afterTaskID: taskID)
+ createItem(title: string, afterItemID: itemID)
}
- coord.deleteSelectedTask = { _ = deleteSelectedTask() }
- coord.moveSelectedTaskUp = { moveSelectedTaskUp() }
- coord.moveSelectedTaskDown = { moveSelectedTaskDown() }
- coord.markSelectedTaskCompleted = { markSelectedTaskCompleted() }
- coord.selectAllTasks = {
- fState.selectAll(displayOrder: allTasksInDisplayOrder.map(\.id))
+ coord.deleteSelectedItem = { _ = deleteSelectedItem() }
+ coord.moveSelectedItemUp = { moveSelectedItemUp() }
+ coord.moveSelectedItemDown = { moveSelectedItemDown() }
+ coord.markSelectedItemCompleted = { markSelectedItemCompleted() }
+ coord.selectAllItems = {
+ fState.selectAll(displayOrder: allItemsInDisplayOrder.map(\.id))
}
- coord.clearCompletedTasks = { clearCompletedTasks() }
+ coord.clearCompletedItems = { clearCompletedItems() }
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
+ 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.canClearCompletedTasks = !completedTasks.isEmpty
+ coord.canClearCompletedItems = !completedItems.isEmpty
}
- init(store: TaskStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
+ init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
self.store = store
self.syncMonitor = syncMonitor
self.windowCoordinator = windowCoordinator
}
- func isRowLifted(_ taskID: UUID) -> Bool {
- iState.liftedTaskID == taskID || draggedTaskID == taskID
+ func isRowLifted(_ itemID: UUID) -> Bool {
+ iState.liftedItemID == itemID || draggedItemID == itemID
}
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle _: Bool) {
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle _: Bool) {
if draftPlacement == placement {
draftPlacement = nil
}
draftTitle = ""
- if fState.selectedTaskID == draftID(for: placement) {
- fState.selectedTaskID = nil
+ 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
@@ -187,52 +187,52 @@ struct TaskListView: View, TaskListViewProtocol {
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,
+ ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
+ let itemID = item.id
+ ItemRowView(
+ item: item,
+ itemID: itemID,
index: index,
- totalTasks: displayActiveTasks.count,
- isSelected: fState.isTaskSelected(taskID),
+ totalItems: displayActiveItems.count,
+ isSelected: fState.isItemSelected(itemID),
focusedField: $focusedFieldBinding,
onToggle: { toggleCompletion($0) },
onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTask($0) },
+ onDelete: { deleteItem($0) },
onSelect: {
let modifiers = NSApp.currentEvent?.modifierFlags ?? []
- selectTask(
+ selectItem(
$0,
extendSelection: modifiers.contains(.shift),
toggleSelection: modifiers.contains(.command)
)
},
onStartEdit: { startEditing($0) },
- onEndEdit: { endEditing($0, shouldCreateNewTask: $1) },
- onPaste: { createTask(title: $0, afterTaskID: taskID) }
+ onEndEdit: { endEditing($0, shouldCreateNewItem: $1) },
+ onPaste: { createItem(title: $0, afterItemID: itemID) }
)
- .taskDragGesture(
- isActive: !task.isCompleted,
- taskID: task.id,
+ .itemDragGesture(
+ isActive: !item.isCompleted,
+ itemID: item.id,
onDragStart: {
- iState.liftedTaskID = nil
- startDrag(taskID: task.id)
+ iState.liftedItemID = nil
+ startDrag(itemID: item.id)
},
- onLift: { iState.liftedTaskID = task.id },
+ onLift: { iState.liftedItemID = item.id },
onLiftEnd: {
- if iState.liftedTaskID == task.id { iState.liftedTaskID = nil }
- if draggedTaskID == task.id { clearDragState() }
+ if iState.liftedItemID == item.id { iState.liftedItemID = nil }
+ if draggedItemID == item.id { clearDragState() }
}
)
- .scaleEffect(isRowLifted(taskID) ? 1.03 : 1.0)
+ .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0)
.shadow(
- color: isRowLifted(taskID) ? .black.opacity(0.2) : .clear,
+ color: isRowLifted(itemID) ? .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))
+ .zIndex(isRowLifted(itemID) ? 1 : 0)
+ .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(itemID))
.overlay {
- if draggedTaskID != nil && draggedTaskID != taskID {
+ if draggedItemID != nil && draggedItemID != itemID {
VStack(spacing: 0) {
// Top 1/6 - insert BEFORE
Color.clear
@@ -240,8 +240,8 @@ struct TaskListView: View, TaskListViewProtocol {
.layoutPriority(1)
.onDrop(
of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrder(insertBefore: taskID) },
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrder(insertBefore: itemID) },
onPerform: { commitCurrentDrag() }
)
)
@@ -252,8 +252,8 @@ struct TaskListView: View, TaskListViewProtocol {
.layoutPriority(4)
.onDrop(
of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrderSmart(relativeTo: taskID) },
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrderSmart(relativeTo: itemID) },
onPerform: { commitCurrentDrag() }
)
)
@@ -264,8 +264,8 @@ struct TaskListView: View, TaskListViewProtocol {
.layoutPriority(1)
.onDrop(
of: [UTType.text],
- delegate: TaskReorderDropDelegate(
- onTargeted: { updateVisualOrder(insertAfter: taskID) },
+ delegate: ItemReorderDropDelegate(
+ onTargeted: { updateVisualOrder(insertAfter: itemID) },
onPerform: { commitCurrentDrag() }
)
)
@@ -275,12 +275,12 @@ struct TaskListView: View, TaskListViewProtocol {
}
if draftPlacement == .append {
- let total = max(1, displayActiveTasks.count + 1)
- let index = displayActiveTasks.count
- let accentColor = cachedTaskColor(
+ let total = max(1, displayActiveItems.count + 1)
+ let index = displayActiveItems.count
+ let accentColor = cachedItemColor(
forIndex: index, total: total, theme: colorTheme
)
- let isSelected = fState.isTaskSelected(draftAppendRowID)
+ let isSelected = fState.isItemSelected(draftAppendRowID)
HStack(alignment: .firstTextBaseline, spacing: 12) {
Image(systemName: "circle")
.foregroundStyle(.primary)
@@ -296,20 +296,20 @@ struct TaskListView: View, TaskListViewProtocol {
set: { iState.draftTitle = $0 }
),
isCompleted: false,
- onEditingChanged: { editing, shouldCreateNewTask in
+ onEditingChanged: { editing, shouldCreateNewItem in
if editing {
- beginDraftTaskEditing(.append)
+ beginDraftItemEditing(.append)
} else {
- commitDraftTask(
- shouldCreateNewTask: shouldCreateNewTask
+ commitDraftItem(
+ shouldCreateNewItem: shouldCreateNewItem
)
}
},
- taskID: draftAppendRowID
+ itemID: draftAppendRowID
)
.focused(
$focusedFieldBinding,
- equals: .task(draftAppendRowID)
+ equals: .item(draftAppendRowID)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -340,18 +340,18 @@ struct TaskListView: View, TaskListViewProtocol {
.id(draftAppendRowID)
}
- ForEach(completedTasks) { task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: fState.isTaskSelected(taskID),
+ 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: { deleteTask($0) },
+ onDelete: { deleteItem($0) },
onSelect: {
- selectTask(
+ selectItem(
$0,
extendSelection: NSEvent.modifierFlags.contains(.shift)
)
@@ -362,14 +362,14 @@ struct TaskListView: View, TaskListViewProtocol {
.frame(maxWidth: .infinity, alignment: .topLeading)
.onDrop(
of: [UTType.text],
- delegate: TaskReorderDropDelegate(
+ delegate: ItemReorderDropDelegate(
onTargeted: {},
onPerform: { commitCurrentDrag() }
)
)
.onChange(of: focusedFieldBinding) { _, newValue in
- if case .task(let id) = (newValue ?? fState.focusedField),
- draggedTaskID == nil,
+ if case .item(let id) = (newValue ?? fState.focusedField),
+ draggedItemID == nil,
id != draftPrependRowID
{
withAnimation {
@@ -377,8 +377,8 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
}
- .onChange(of: fState.selectedTaskID) { _, newID in
- if let newID, draggedTaskID == nil {
+ .onChange(of: fState.selectedItemID) { _, newID in
+ if let newID, draggedItemID == nil {
guard newID != draftPrependRowID else { return }
withAnimation {
scrollProxy.scrollTo(newID)
@@ -389,7 +389,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
.onDrop(
of: [UTType.text],
- delegate: TaskReorderDropDelegate(
+ delegate: ItemReorderDropDelegate(
onTargeted: {},
onPerform: { commitCurrentDrag() }
)
@@ -412,13 +412,13 @@ struct TaskListView: View, TaskListViewProtocol {
.focusable()
.focused($focusedFieldBinding, equals: .scrollView)
.focusEffectDisabled()
- .accessibilityIdentifier("task-list-scrollview")
+ .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): focusSelectedTask,
+ ShortcutKey(key: .return): focusSelectedItem,
])
.onAppear {
if focusedFieldBinding == nil {
@@ -466,7 +466,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
-private struct TaskReorderDropDelegate: DropDelegate {
+private struct ItemReorderDropDelegate: DropDelegate {
let onTargeted: () -> Void
let onPerform: () -> Bool
diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/ItemRowView.swift
@@ -1,17 +1,17 @@
import SwiftUI
-struct TaskRowView: View {
- let task: TaskItem
- let taskID: UUID
+struct ItemRowView: View {
+ let item: ItemEntity
+ let itemID: UUID
let index: Int
- let totalTasks: Int
+ let totalItems: Int
let isSelected: Bool
- let onToggle: (TaskItem) -> Void
- let onTitleChange: (TaskItem, String) -> Void
- let onDelete: (TaskItem) -> Void
+ let onToggle: (ItemEntity) -> Void
+ let onTitleChange: (ItemEntity, String) -> Void
+ let onDelete: (ItemEntity) -> Void
let onSelect: (UUID) -> Void
let onStartEdit: (UUID) -> Void
- let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void
+ let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
let onPaste: (String) -> Void
@FocusState.Binding var focusedField: FocusField?
@@ -32,29 +32,29 @@ struct TaskRowView: View {
@MainActor
private func computeAccentColor() -> Color {
- guard !task.isCompleted else { return .clear }
- return cachedTaskColor(forIndex: index, total: totalTasks, theme: colorTheme)
+ guard !item.isCompleted else { return .clear }
+ return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
}
init(
- task: TaskItem,
- taskID: UUID,
+ item: ItemEntity,
+ itemID: UUID,
index: Int = 0,
- totalTasks: Int = 1,
+ totalItems: Int = 1,
isSelected: Bool,
focusedField: FocusState<FocusField?>.Binding,
- onToggle: @escaping (TaskItem) -> Void,
- onTitleChange: @escaping (TaskItem, String) -> Void,
- onDelete: @escaping (TaskItem) -> Void,
+ 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, _ shouldCreateNewTask: Bool) -> Void = { _, _ in },
+ onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in },
onPaste: @escaping (String) -> Void = { _ in }
) {
- self.task = task
- self.taskID = taskID
+ self.item = item
+ self.itemID = itemID
self.index = index
- self.totalTasks = totalTasks
+ self.totalItems = totalItems
self.isSelected = isSelected
self.onToggle = onToggle
self.onTitleChange = onTitleChange
@@ -69,10 +69,10 @@ struct TaskRowView: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Button {
- onToggle(task)
+ onToggle(item)
} label: {
- Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
- .foregroundStyle(task.isCompleted ? .secondary : .primary)
+ Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(item.isCompleted ? .secondary : .primary)
.font(.system(size: 17))
.fontWeight(.thin)
}
@@ -80,30 +80,30 @@ struct TaskRowView: View {
.alignmentGuide(.firstTextBaseline) { d in
d[VerticalAlignment.center] + 5
}
- .accessibilityIdentifier("task-checkbox")
- .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
+ .accessibilityIdentifier("item-checkbox")
+ .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
ClickableTextField(
text: $editingTitle,
- isCompleted: task.isCompleted,
- onEditingChanged: { editing, shouldCreateNewTask in
+ isCompleted: item.isCompleted,
+ onEditingChanged: { editing, shouldCreateNewItem in
isCurrentlyEditing = editing
if editing {
- onStartEdit(taskID)
+ onStartEdit(itemID)
} else {
- onEndEdit(taskID, shouldCreateNewTask)
+ onEndEdit(itemID, shouldCreateNewItem)
}
},
- taskID: taskID,
+ itemID: itemID,
onContentChange: { newTitle in
- guard !task.isCompleted else { return }
- onTitleChange(task, newTitle)
+ guard !item.isCompleted else { return }
+ onTitleChange(item, newTitle)
}
)
- .focused($focusedField, equals: .task(taskID))
+ .focused($focusedField, equals: .item(itemID))
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier(
- isCurrentlyEditing ? "task-textfield" : "task-text-\(taskID.uuidString)")
+ isCurrentlyEditing ? "item-textfield" : "item-text-\(itemID.uuidString)")
}
.padding(.top, 4)
.padding(.vertical, 8)
@@ -111,7 +111,7 @@ struct TaskRowView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
- onSelect(taskID)
+ onSelect(itemID)
}
.background(selectionBackground)
.overlay(alignment: .leading) {
@@ -123,8 +123,8 @@ struct TaskRowView: View {
}
.overlay(alignment: .bottom) {
// Hairline border between rows, inset to align with text
- // Only show for active (non-completed) tasks
- if !task.isCompleted {
+ // Only show for active (non-completed) items
+ if !item.isCompleted {
Rectangle()
.fill(.separator)
.frame(height: 0.5)
@@ -132,14 +132,14 @@ struct TaskRowView: View {
}
}
.overlay {
- if isSelected && !task.isCompleted {
+ if isSelected && !item.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)
+ Button(item.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
+ onToggle(item)
}
Divider()
Button("Cut") {
@@ -151,14 +151,14 @@ struct TaskRowView: View {
Button("Paste") {
pasteFromPasteboard()
}
- .disabled(task.isCompleted)
+ .disabled(item.isCompleted)
Divider()
Button("Delete", role: .destructive) {
- onDelete(task)
+ onDelete(item)
}
}
- .onChange(of: task.title) { _, newValue in
- // Keep editingTitle in sync with task.title when not editing
+ .onChange(of: item.title) { _, newValue in
+ // Keep editingTitle in sync with item.title when not editing
if !isCurrentlyEditing {
editingTitle = newValue
}
@@ -169,12 +169,12 @@ struct TaskRowView: View {
.onChange(of: index) { _, _ in
cachedAccentColor = computeAccentColor()
}
- .onChange(of: totalTasks) { _, _ in
+ .onChange(of: totalItems) { _, _ in
cachedAccentColor = computeAccentColor()
}
.onAppear {
// Initialize editingTitle and cache accent color (computed once)
- editingTitle = task.title
+ editingTitle = item.title
cachedAccentColor = computeAccentColor()
}
}
@@ -182,7 +182,7 @@ struct TaskRowView: View {
@ViewBuilder
private var selectionBackground: some View {
if isSelected {
- if task.isCompleted {
+ if item.isCompleted {
Color(nsColor: .controlBackgroundColor)
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
@@ -197,14 +197,14 @@ struct TaskRowView: View {
private func cutToPasteboard() {
copyToPasteboard()
- onDelete(task)
+ onDelete(item)
}
private func copyToPasteboard() {
- guard !task.title.isEmpty else { return }
+ guard !item.title.isEmpty else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
- pasteboard.setString(task.title, forType: .string)
+ pasteboard.setString(item.title, forType: .string)
}
private func pasteFromPasteboard() {
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/TaskListView.swift b/ListlessWatch/Views/ItemListView.swift
@@ -1,28 +1,28 @@
import CoreData
import SwiftUI
-struct TaskListView: View {
- let store: TaskStore
+struct ItemListView: View {
+ let store: ItemStore
let syncMonitor: CloudKitSyncMonitor
@AppStorage("headingText") private var headingText = "Items"
@FetchRequest(
sortDescriptors: [
- SortDescriptor(\TaskItem.sortOrder, order: .forward),
+ SortDescriptor(\ItemEntity.sortOrder, order: .forward),
],
animation: .default
)
- private var tasks: FetchedResults<TaskItem>
+ private var items: FetchedResults<ItemEntity>
var body: some View {
- let activeTasks = tasks.filter { !$0.isCompleted }
- let completedTasks = tasks.filter { $0.isCompleted }
+ let activeItems = items.filter { !$0.isCompleted }
+ let completedItems = items.filter { $0.isCompleted }
.sorted { $0.completedOrder > $1.completedOrder }
NavigationStack {
Group {
- if tasks.isEmpty {
+ if items.isEmpty {
ContentUnavailableView(
"No Items",
systemImage: "checklist",
@@ -30,23 +30,23 @@ struct TaskListView: View {
)
} else {
List {
- ForEach(Array(activeTasks.enumerated()), id: \.element.id) { index, task in
- TaskRowView(
- task: task,
+ ForEach(Array(activeItems.enumerated()), id: \.element.id) { index, item in
+ ItemRowView(
+ item: item,
index: index,
- totalActive: activeTasks.count,
- onToggle: { toggleTask($0) }
+ totalActive: activeItems.count,
+ onToggle: { toggleItem($0) }
)
}
- if !completedTasks.isEmpty {
+ if !completedItems.isEmpty {
Section("Completed") {
- ForEach(completedTasks) { task in
- TaskRowView(
- task: task,
+ ForEach(completedItems) { item in
+ ItemRowView(
+ item: item,
index: 0,
totalActive: 0,
- onToggle: { toggleTask($0) }
+ onToggle: { toggleItem($0) }
)
}
}
@@ -58,12 +58,12 @@ struct TaskListView: View {
}
}
- private func toggleTask(_ task: TaskItem) {
+ private func toggleItem(_ item: ItemEntity) {
do {
- if task.isCompleted {
- try store.uncomplete(taskID: task.id)
+ if item.isCompleted {
+ try store.uncomplete(itemID: item.id)
} else {
- try store.complete(taskID: task.id)
+ try store.complete(itemID: item.id)
}
} catch {
// Sync monitor handles error reporting
diff --git a/ListlessWatch/Views/TaskRowView.swift b/ListlessWatch/Views/ItemRowView.swift
@@ -1,39 +1,39 @@
import SwiftUI
-struct TaskRowView: View {
- let task: TaskItem
+struct ItemRowView: View {
+ let item: ItemEntity
let index: Int
let totalActive: Int
- let onToggle: (TaskItem) -> Void
+ let onToggle: (ItemEntity) -> Void
var body: some View {
Button {
- onToggle(task)
+ onToggle(item)
} label: {
HStack(spacing: 8) {
- Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
+ Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(
- task.isCompleted
+ item.isCompleted
? .secondary
- : cachedTaskColor(forIndex: index, total: totalActive)
+ : cachedItemColor(forIndex: index, total: totalActive)
)
.font(.system(size: 17))
- Text(task.title)
- .strikethrough(task.isCompleted)
- .foregroundStyle(task.isCompleted ? .secondary : .primary)
+ Text(item.title)
+ .strikethrough(item.isCompleted)
+ .foregroundStyle(item.isCompleted ? .secondary : .primary)
.lineLimit(3)
}
.padding(.vertical, 4)
}
.listRowBackground(
- task.isCompleted
+ item.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)
+ cachedItemColor(forIndex: index, total: totalActive)
.frame(height: 3)
}
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/ItemListView+Drag.swift
@@ -1,8 +1,8 @@
import SwiftUI
-extension TaskListView {
- func handleIOSDragChanged(taskID: UUID, point: CGPoint) {
- guard let draggedID = draggedTaskID,
+extension ItemListView {
+ func handleIOSDragChanged(itemID: UUID, point: CGPoint) {
+ guard let draggedID = draggedItemID,
var order = visualOrder,
let currentIndex = order.firstIndex(of: draggedID) else { return }
@@ -30,7 +30,7 @@ extension TaskListView {
}
func commitIOSDrag() {
- guard let draggedID = draggedTaskID,
+ guard let draggedID = draggedItemID,
let order = visualOrder,
let finalIndex = order.firstIndex(of: draggedID) else {
clearDragState()
@@ -38,7 +38,7 @@ extension TaskListView {
return
}
do {
- try store.moveTask(taskID: draggedID, toIndex: finalIndex)
+ try store.moveItem(itemID: draggedID, toIndex: finalIndex)
clearDragState()
isDragging = false
} catch {
diff --git a/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift b/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift
@@ -1,6 +1,6 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
@ViewBuilder
private var settingsButton: some View {
Button {
@@ -41,7 +41,7 @@ extension TaskListView {
.padding(.bottom, 8)
.contentShape(Rectangle())
.onTapGesture {
- fState.selectedTaskID = nil
+ fState.selectedItemID = nil
focusedField = nil
}
}
diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/ItemListView+PullGestures.swift
@@ -1,10 +1,10 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
struct PullToCreateState {
enum Action {
case none
- case createTask
+ case createItem
case collapseIndicator
}
@@ -53,7 +53,7 @@ extension TaskListView {
if pullOffset >= pullThreshold || isFlick {
isInsertionPending = true
- return .createTask
+ return .createItem
}
isInsertionPending = false
@@ -63,18 +63,18 @@ extension TaskListView {
}
private struct PullGesturesModifier: ViewModifier {
- @Binding var pullToCreate: TaskListView.PullToCreateState
+ @Binding var pullToCreate: ItemListView.PullToCreateState
@Binding var pullUpOffset: CGFloat
@AppStorage("hapticsEnabled") private var hapticsEnabled = true
@State private var isScrollInteracting = false
let isDraftOpen: Bool
- let hasCompletedTasks: Bool
+ let hasCompletedItems: Bool
let pullCreateThreshold: CGFloat
let flickThreshold: CGFloat
let pullClearThreshold: CGFloat
- let onCreateTaskAtTop: () -> UUID
+ let onCreateItemAtTop: () -> UUID
let onClearCompleted: () -> Void
func body(content: Content) -> some View {
@@ -92,7 +92,7 @@ private struct PullGesturesModifier: ViewModifier {
)
return max(0, geo.contentOffset.y - maxOffset)
} action: { _, bottomOverscroll in
- guard hasCompletedTasks, isScrollInteracting else { return }
+ guard hasCompletedItems, isScrollInteracting else { return }
pullUpOffset = bottomOverscroll
}
.onScrollPhaseChange { oldPhase, newPhase in
@@ -130,11 +130,11 @@ private struct PullGesturesModifier: ViewModifier {
guard oldPhase == .interacting, newPhase != .interacting else { return }
switch action {
- case .createTask:
+ case .createItem:
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) {
- _ = onCreateTaskAtTop()
+ _ = onCreateItemAtTop()
}
case .collapseIndicator:
withAnimation(.spring(response: 0.22, dampingFraction: 1.0)) {
@@ -146,7 +146,7 @@ private struct PullGesturesModifier: ViewModifier {
}
private func handlePullToClearRelease() {
- guard hasCompletedTasks, pullUpOffset >= pullClearThreshold else {
+ guard hasCompletedItems, pullUpOffset >= pullClearThreshold else {
pullUpOffset = 0
return
}
@@ -157,14 +157,14 @@ private struct PullGesturesModifier: ViewModifier {
extension View {
func pullGestures(
- pullToCreate: Binding<TaskListView.PullToCreateState>,
+ pullToCreate: Binding<ItemListView.PullToCreateState>,
pullUpOffset: Binding<CGFloat>,
isDraftOpen: Bool,
- hasCompletedTasks: Bool,
+ hasCompletedItems: Bool,
pullCreateThreshold: CGFloat,
flickThreshold: CGFloat,
pullClearThreshold: CGFloat,
- onCreateTaskAtTop: @escaping () -> UUID,
+ onCreateItemAtTop: @escaping () -> UUID,
onClearCompleted: @escaping () -> Void
) -> some View {
modifier(
@@ -172,11 +172,11 @@ extension View {
pullToCreate: pullToCreate,
pullUpOffset: pullUpOffset,
isDraftOpen: isDraftOpen,
- hasCompletedTasks: hasCompletedTasks,
+ hasCompletedItems: hasCompletedItems,
pullCreateThreshold: pullCreateThreshold,
flickThreshold: flickThreshold,
pullClearThreshold: pullClearThreshold,
- onCreateTaskAtTop: onCreateTaskAtTop,
+ onCreateItemAtTop: onCreateItemAtTop,
onClearCompleted: onClearCompleted
)
)
diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/ItemListView+PullToClear.swift
@@ -1,8 +1,8 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
@ViewBuilder var pullToClearIndicatorRow: some View {
- if pState.pullUpOffset > 0 && !completedTasks.isEmpty {
+ if pState.pullUpOffset > 0 && !completedItems.isEmpty {
PullToClearIndicator(pullOffset: pState.pullUpOffset)
}
}
diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/ItemListView+PullToCreate.swift
@@ -1,26 +1,26 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
// MARK: - Pull-to-Create Draft Helpers
func revealPhantomRow() -> UUID {
- let taskID = draftPrependRowID
+ let itemID = draftPrependRowID
if draftPlacement != .prepend, draftPlacement != nil {
- commitDraftTask()
+ commitDraftItem()
}
clearDragState()
draftTitle = ""
draftPlacement = .prepend
- fState.selectedTaskID = taskID
- fState.pendingFocus = .task(taskID)
- focusedField = .task(taskID)
+ fState.selectedItemID = itemID
+ fState.pendingFocus = .item(itemID)
+ focusedField = .item(itemID)
- return taskID
+ return itemID
}
func commitPhantomRow() {
- commitDraftTask()
+ commitDraftItem()
}
}
diff --git a/ListlessiOS/Extensions/TaskListView+Toolbar.swift b/ListlessiOS/Extensions/ItemListView+Toolbar.swift
@@ -1,9 +1,9 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
@ToolbarContentBuilder
var platformToolbar: some ToolbarContent {
- // No toolbar on iOS - users interact with tasks directly
+ // 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/TaskListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift
@@ -1,33 +1,33 @@
import SwiftUI
-extension TaskListView {
+extension ItemListView {
- func deleteTaskWithUndo(_ task: TaskItem) {
- deleteTask(task)
+ func deleteItemWithUndo(_ item: ItemEntity) {
+ deleteItem(item)
showUndoToast(message: "Item deleted")
}
- func deleteSelectedTaskWithUndo() -> KeyPress.Result {
+ func deleteSelectedItemWithUndo() -> KeyPress.Result {
guard focusedField == .scrollView else {
return .ignored
}
- guard let currentID = fState.selectedTaskID else {
+ guard let currentID = fState.selectedItemID else {
return .handled
}
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
+ guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
return .handled
}
- deleteTaskWithUndo(task)
+ deleteItemWithUndo(item)
return .handled
}
- func clearCompletedTasksWithUndo() {
- let ids = completedTasks.map(\.id)
+ func clearCompletedItemsWithUndo() {
+ let ids = completedItems.map(\.id)
guard !ids.isEmpty else { return }
let count = ids.count
managedObjectContext.undoManager?.beginUndoGrouping()
do {
- try store.deleteMultiple(taskIDs: ids)
+ try store.deleteMultiple(itemIDs: ids)
} catch {
presentStoreError(error)
managedObjectContext.undoManager?.endUndoGrouping()
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/TaskCardModifier.swift b/ListlessiOS/Helpers/ItemCardModifier.swift
@@ -1,13 +1,13 @@
import SwiftUI
-struct TaskCardModifier: ViewModifier {
+struct ItemCardModifier: ViewModifier {
var accentColor: Color
var isSelected: Bool
static let shape = UnevenRoundedRectangle(
topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
+ bottomTrailingRadius: ItemRowMetrics.trailingCornerRadius,
+ topTrailingRadius: ItemRowMetrics.trailingCornerRadius
)
func body(content: Content) -> some View {
@@ -16,7 +16,7 @@ struct TaskCardModifier: ViewModifier {
.overlay(alignment: .leading) {
Rectangle()
.fill(accentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
+ .frame(width: ItemRowMetrics.accentBarWidth)
}
.overlay(
isSelected
@@ -28,7 +28,7 @@ struct TaskCardModifier: ViewModifier {
}
extension View {
- func taskCard(accentColor: Color, isSelected: Bool) -> some View {
- modifier(TaskCardModifier(accentColor: accentColor, isSelected: isSelected))
+ func itemCard(accentColor: Color, isSelected: Bool) -> some View {
+ modifier(ItemCardModifier(accentColor: accentColor, isSelected: isSelected))
}
}
diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/ItemRowDragGesture.swift
@@ -1,17 +1,17 @@
import SwiftUI
extension View {
- func taskDragGesture(
+ func itemDragGesture(
isActive: Bool,
- taskID: UUID,
+ itemID: UUID,
onDragStart: @escaping (CGFloat) -> Void,
onDragChanged: @escaping (CGPoint) -> Void,
onDragEnded: @escaping () -> Void
) -> some View {
self.modifier(
- TaskRowDragGesture(
+ ItemRowDragGesture(
isActive: isActive,
- taskID: taskID,
+ itemID: itemID,
onDragStart: onDragStart,
onDragChanged: onDragChanged,
onDragEnded: onDragEnded
@@ -19,9 +19,9 @@ extension View {
}
}
-struct TaskRowDragGesture: ViewModifier {
+struct ItemRowDragGesture: ViewModifier {
let isActive: Bool
- let taskID: UUID
+ let itemID: UUID
let onDragStart: (CGFloat) -> Void
let onDragChanged: (CGPoint) -> Void
let onDragEnded: () -> Void
diff --git a/ListlessiOS/Helpers/TaskRowMetrics.swift b/ListlessiOS/Helpers/ItemRowMetrics.swift
@@ -2,8 +2,8 @@ import CoreGraphics
import SwiftUI
import UIKit
-enum TaskRowMetrics {
- /// Base task-title font size (18pt), scaled by Dynamic Type.
+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).
diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/ItemRowSwipeGesture.swift
@@ -1,19 +1,19 @@
import SwiftUI
extension View {
- func taskSwipeGesture(
+ func itemSwipeGesture(
isDragging: Binding<Bool>,
isEditing: Bool,
isSwiping: Binding<Bool>,
swipeOffset: Binding<CGFloat>,
- swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
+ swipeDirection: Binding<ItemRowSwipeGesture.SwipeDirection>,
isTriggered: Binding<Bool>,
completeColor: Color = .green,
onComplete: @escaping () -> Void,
onDelete: @escaping () -> Void
) -> some View {
self.modifier(
- TaskRowSwipeGesture(
+ ItemRowSwipeGesture(
isDragging: isDragging,
isEditing: isEditing,
isSwiping: isSwiping,
@@ -27,7 +27,7 @@ extension View {
}
}
-struct TaskRowSwipeGesture: ViewModifier {
+struct ItemRowSwipeGesture: ViewModifier {
@Binding var isDragging: Bool
let isEditing: Bool
@Binding var isSwiping: Bool
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/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/TaskListView.swift b/ListlessiOS/Views/ItemListView.swift
@@ -1,7 +1,7 @@
import SwiftUI
import UIKit
-struct TaskListView: View, TaskListViewProtocol {
+struct ItemListView: View, ItemListViewProtocol {
class LayoutStorage {
var draggedRowWidth: CGFloat = 0
var draggedRowFrame: CGRect = .zero
@@ -13,10 +13,10 @@ struct TaskListView: View, TaskListViewProtocol {
var draftCount: Int = 0
var isShowingSyncDiagnostics = false
var isShowingSettings = false
- var clearingTaskIDs: Set<UUID> = []
+ var clearingItemIDs: Set<UUID> = []
var undoToast: UndoToastData? = nil
var isSwiping: Bool = false
- var draftPlacement: DraftTaskPlacement?
+ var draftPlacement: DraftItemPlacement?
var draftTitle: String = ""
var fetchWorkaround: Int = 0
}
@@ -36,13 +36,13 @@ struct TaskListView: View, TaskListViewProtocol {
@Environment(\.undoManager) var undoManager
@Environment(\.managedObjectContext) var managedObjectContext
- let store: TaskStore
+ let store: ItemStore
@ObservedObject var syncMonitor: CloudKitSyncMonitor
@FetchRequest(
sortDescriptors: [],
animation: .default
)
- var tasks: FetchedResults<TaskItem>
+ var items: FetchedResults<ItemEntity>
@FocusState private var focusedFieldBinding: FocusField?
@State var fState = FocusStateData()
@State var iState = InteractionStateData()
@@ -63,7 +63,7 @@ struct TaskListView: View, TaskListViewProtocol {
nonmutating set { iState.dragState = newValue }
}
- var draftPlacement: DraftTaskPlacement? {
+ var draftPlacement: DraftItemPlacement? {
get { iState.draftPlacement }
nonmutating set {
if newValue != nil, iState.draftPlacement == nil {
@@ -119,8 +119,8 @@ struct TaskListView: View, TaskListViewProtocol {
}
private var selectedIndex: Int? {
- guard let currentID = fState.selectedTaskID else { return nil }
- return activeTasks.firstIndex(where: { $0.id == currentID })
+ guard let currentID = fState.selectedItemID else { return nil }
+ return activeItems.firstIndex(where: { $0.id == currentID })
}
private var canMoveSelectionUp: Bool {
@@ -132,40 +132,40 @@ struct TaskListView: View, TaskListViewProtocol {
private var canMoveSelectionDown: Bool {
guard focusedField == .scrollView else { return false }
guard let index = selectedIndex else { return false }
- return index < activeTasks.count - 1
+ return index < activeItems.count - 1
}
private struct MenuState: Equatable {
- let selectedTaskID: UUID?
+ let selectedItemID: UUID?
let isScrollViewFocused: Bool
- let activeTaskCount: Int
- let completedTaskCount: Int
+ let activeItemCount: Int
+ let completedItemCount: Int
let selectedIndex: Int?
}
private var menuCoordinatorTrigger: MenuState {
MenuState(
- selectedTaskID: fState.selectedTaskID,
+ selectedItemID: fState.selectedItemID,
isScrollViewFocused: focusedField == .scrollView,
- activeTaskCount: activeTasks.count,
- completedTaskCount: completedTasks.count,
+ activeItemCount: activeItems.count,
+ completedItemCount: completedItems.count,
selectedIndex: selectedIndex
)
}
func updateMenuCoordinator() {
let coord = IOSMenuCoordinator.shared
- coord.newTask = { createNewTask() }
- coord.deleteTask = { _ = deleteSelectedTaskWithUndo() }
- coord.moveUp = { moveSelectedTaskUp() }
- coord.moveDown = { moveSelectedTaskDown() }
- coord.markCompleted = { markSelectedTaskCompleted() }
+ coord.newItem = { createNewItem() }
+ coord.deleteItem = { _ = deleteSelectedItemWithUndo() }
+ coord.moveUp = { moveSelectedItemUp() }
+ coord.moveDown = { moveSelectedItemDown() }
+ coord.markCompleted = { markSelectedItemCompleted() }
let inNavMode = focusedField == .scrollView
- coord.canDelete = fState.selectedTaskID != nil && inNavMode
+ coord.canDelete = fState.selectedItemID != nil && inNavMode
coord.canMoveUp = canMoveSelectionUp
coord.canMoveDown = canMoveSelectionDown
- coord.canMarkCompleted = fState.selectedTaskID != nil && inNavMode
- coord.markCompletedTitle = completedTasks.contains(where: { $0.id == fState.selectedTaskID })
+ coord.canMarkCompleted = fState.selectedItemID != nil && inNavMode
+ coord.markCompletedTitle = completedItems.contains(where: { $0.id == fState.selectedItemID })
? "Mark as Incomplete" : "Mark as Complete"
}
@@ -173,21 +173,21 @@ struct TaskListView: View, TaskListViewProtocol {
var rowGap: CGFloat { 12 }
var pullCreateThreshold: CGFloat { 70 }
var flickThreshold: CGFloat { 500 }
- var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
+ var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
- init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) {
+ init(store: ItemStore, syncMonitor: CloudKitSyncMonitor) {
self.store = store
self.syncMonitor = syncMonitor
}
- func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool) {
+ func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
let clear: () -> Void = {
if draftPlacement == placement {
draftPlacement = nil
}
draftTitle = ""
- if fState.selectedTaskID == draftID(for: placement) {
- fState.selectedTaskID = nil
+ if fState.selectedItemID == draftID(for: placement) {
+ fState.selectedItemID = nil
}
guard placement == .prepend else { return }
@@ -264,22 +264,22 @@ struct TaskListView: View, TaskListViewProtocol {
.opacity(opacity)
}
- /// The draft row content styled to match a task row. Controlled by the
+ /// 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: taskColor(
- forIndex: 0, total: max(1, displayActiveTasks.count + 1), theme: colorTheme
+ accentColor: itemColor(
+ forIndex: 0, total: max(1, displayActiveItems.count + 1), theme: colorTheme
),
- isSelected: fState.selectedTaskID == draftPrependRowID,
+ isSelected: fState.selectedItemID == draftPrependRowID,
draftID: draftPrependRowID,
title: draftTitleBinding,
onEditingChanged: { editing, _ in
DispatchQueue.main.async {
if editing {
- beginDraftTaskEditing(.prepend)
+ beginDraftItemEditing(.prepend)
} else {
- commitDraftTask()
+ commitDraftItem()
}
}
},
@@ -292,21 +292,21 @@ struct TaskListView: View, TaskListViewProtocol {
@ViewBuilder private var draftAppendRow: some View {
if isAppendDraftVisible {
DraftRowView(
- accentColor: taskColor(
- forIndex: displayActiveTasks.count,
- total: max(1, displayActiveTasks.count + 1),
+ accentColor: itemColor(
+ forIndex: displayActiveItems.count,
+ total: max(1, displayActiveItems.count + 1),
theme: colorTheme
),
- isSelected: fState.selectedTaskID == draftAppendRowID,
+ isSelected: fState.selectedItemID == draftAppendRowID,
draftID: draftAppendRowID,
title: draftTitleBinding,
- onEditingChanged: { editing, shouldCreateNewTask in
+ onEditingChanged: { editing, shouldCreateNewItem in
DispatchQueue.main.async {
if editing {
- beginDraftTaskEditing(.append)
+ beginDraftItemEditing(.append)
} else {
- commitDraftTask(
- shouldCreateNewTask: shouldCreateNewTask
+ commitDraftItem(
+ shouldCreateNewItem: shouldCreateNewItem
)
}
}
@@ -322,53 +322,53 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
- @ViewBuilder private var taskRows: some View {
+ @ViewBuilder private var itemRows: 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,
+ ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
+ let itemID = item.id
+ ItemRowView(
+ item: item,
+ itemID: itemID,
index: index + draftOffset,
- totalTasks: displayActiveTasks.count + draftTotal,
- isSelected: fState.selectedTaskID == taskID,
+ totalItems: displayActiveItems.count + draftTotal,
+ isSelected: fState.selectedItemID == itemID,
isDragging: isDraggingStateBinding,
isSwiping: $iState.isSwiping,
- isLastActiveTask: index == displayActiveTasks.count - 1,
+ isLastActiveItem: index == displayActiveItems.count - 1,
focusedField: $focusedFieldBinding,
onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) },
+ onDelete: { deleteItemWithUndo($0) },
+ onSelect: { selectItem($0) },
onStartEdit: { startEditing($0) },
onEndEdit: {
- if fState.selectedTaskID == $0 {
- fState.selectedTaskID = nil
+ if fState.selectedItemID == $0 {
+ fState.selectedItemID = nil
}
- endEditing($0, shouldCreateNewTask: $1)
+ endEditing($0, shouldCreateNewItem: $1)
}
)
- .scaleEffect(draggedTaskID == taskID ? dragScaleEffect() : 1.0)
+ .scaleEffect(draggedItemID == itemID ? dragScaleEffect() : 1.0)
.shadow(
- color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
+ color: draggedItemID == itemID ? .black.opacity(0.3) : .clear,
radius: 12, y: 4
)
- .taskDragGesture(
- isActive: !task.isCompleted && focusedFieldBinding != .task(taskID),
- taskID: taskID,
+ .itemDragGesture(
+ isActive: !item.isCompleted && focusedFieldBinding != .item(itemID),
+ itemID: itemID,
onDragStart: { width in
layoutStorage.draggedRowWidth = width
- startDrag(taskID: taskID)
+ startDrag(itemID: itemID)
},
onDragChanged: { point in
- handleIOSDragChanged(taskID: taskID, point: point)
+ handleIOSDragChanged(itemID: itemID, point: point)
},
onDragEnded: { commitIOSDrag() }
)
.background {
- if draggedTaskID == taskID {
+ if draggedItemID == itemID {
Color.clear
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
@@ -378,35 +378,35 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
.padding(.bottom, rowGap)
- .zIndex(draggedTaskID == taskID ? 2 : 1)
- .id(taskID)
+ .zIndex(draggedItemID == itemID ? 2 : 1)
+ .id(itemID)
}
draftAppendRow
- ForEach(completedTasks) { task in
- let taskID = task.id
- let isBeingCleared = iState.clearingTaskIDs.contains(taskID)
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: fState.selectedTaskID == taskID,
+ 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: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) }
+ onDelete: { deleteItemWithUndo($0) },
+ onSelect: { selectItem($0) }
)
.opacity(isBeingCleared ? 0 : 1)
.offset(y: isBeingCleared ? 40 : 0)
.padding(.bottom, rowGap)
- .id(taskID)
+ .id(itemID)
}
}
var body: some View {
- taskScrollView
+ itemScrollView
.overlay(alignment: .topLeading) {
if showFPSOverlay {
FPSOverlay()
@@ -421,17 +421,17 @@ struct TaskListView: View, TaskListViewProtocol {
handleBackgroundTap()
}
)
- .accessibilityIdentifier("task-list-scrollview")
+ .accessibilityIdentifier("item-list-scrollview")
.background {
- let isEditing = if case .task = focusedFieldBinding { true } else { false }
+ let isEditing = if case .item = focusedFieldBinding { true } else { false }
let isShowingSheet = iState.isShowingSettings || iState.isShowingSyncDiagnostics
KeyCommandBridge(
isActive: !isEditing && !isShowingSheet,
onUp: { _ = navigateUp() },
onDown: { _ = navigateDown() },
- onSpace: { _ = toggleSelectedTask() },
- onReturn: { _ = focusSelectedTask() },
- onDelete: { _ = deleteSelectedTaskWithUndo() }
+ onSpace: { _ = toggleSelectedItem() },
+ onReturn: { _ = focusSelectedItem() },
+ onDelete: { _ = deleteSelectedItemWithUndo() }
)
}
.onAppear {
@@ -483,7 +483,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
- private var taskScrollView: some View {
+ private var itemScrollView: some View {
ZStack(alignment: .top) {
ScrollView {
ScrollViewReader { scrollProxy in
@@ -495,7 +495,7 @@ struct TaskListView: View, TaskListViewProtocol {
draftPrependRow
.padding(.bottom, rowGap)
}
- taskRows
+ itemRows
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.onGeometryChange(for: CGFloat.self) {
@@ -523,8 +523,8 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
- if case .task(let id) = (newValue ?? fState.focusedField),
- draggedTaskID == nil,
+ if case .item(let id) = (newValue ?? fState.focusedField),
+ draggedItemID == nil,
id != draftPrependRowID
{
withAnimation {
@@ -532,8 +532,8 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
}
- .onChange(of: fState.selectedTaskID) { _, newID in
- if let newID, draggedTaskID == nil {
+ .onChange(of: fState.selectedItemID) { _, newID in
+ if let newID, draggedItemID == nil {
guard newID != draftPrependRowID else { return }
withAnimation {
scrollProxy.scrollTo(newID)
@@ -542,7 +542,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
}
- .scrollDisabled(draggedTaskID != nil || iState.isSwiping)
+ .scrollDisabled(draggedItemID != nil || iState.isSwiping)
.scrollBounceBehavior(.always)
.contentMargins(.bottom, 20)
.background {
@@ -551,7 +551,7 @@ struct TaskListView: View, TaskListViewProtocol {
.overlay {
if isCompletelyEmpty && draftPlacement == nil {
Text("Pull down to create")
- .font(TaskRowMetrics.hintSUI)
+ .font(ItemRowMetrics.hintSUI)
.foregroundStyle(.secondary)
.padding(.top, 24)
.allowsHitTesting(false)
@@ -564,18 +564,18 @@ struct TaskListView: View, TaskListViewProtocol {
pullToCreate: pullToCreateStateBinding,
pullUpOffset: pullUpOffsetStateBinding,
isDraftOpen: draftPlacement != nil,
- hasCompletedTasks: !completedTasks.isEmpty,
+ hasCompletedItems: !completedItems.isEmpty,
pullCreateThreshold: pullCreateThreshold,
flickThreshold: flickThreshold,
pullClearThreshold: pullClearThreshold,
- onCreateTaskAtTop: { revealPhantomRow() },
+ onCreateItemAtTop: { revealPhantomRow() },
onClearCompleted: {
- let ids = Set(completedTasks.map(\.id))
+ let ids = Set(completedItems.map(\.id))
withAnimation(.easeIn(duration: 0.35)) {
- iState.clearingTaskIDs = ids
+ iState.clearingItemIDs = ids
} completion: {
- iState.clearingTaskIDs = []
- clearCompletedTasksWithUndo()
+ iState.clearingItemIDs = []
+ clearCompletedItemsWithUndo()
}
}
)
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/ItemRowView.swift
@@ -1,26 +1,26 @@
import SwiftUI
-struct TaskRowView: View {
- let task: TaskItem
- let taskID: UUID
+struct ItemRowView: View {
+ let item: ItemEntity
+ let itemID: UUID
let index: Int
- let totalTasks: Int
+ let totalItems: 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 onToggle: (ItemEntity) -> Void
+ let onTitleChange: (ItemEntity, String) -> Void
+ let onDelete: (ItemEntity) -> Void
let onSelect: (UUID) -> Void
- let isLastActiveTask: Bool
+ let isLastActiveItem: Bool
let onStartEdit: (UUID) -> Void
- let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> 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: TaskRowSwipeGesture.SwipeDirection = .none
+ @State private var swipeDirection: ItemRowSwipeGesture.SwipeDirection = .none
@State private var isSwipeTriggered: Bool = false
@State private var editingTitle: String = ""
@State private var isCurrentlyEditing: Bool = false
@@ -28,30 +28,30 @@ struct TaskRowView: View {
@State private var cachedAccentColor: Color = .clear
init(
- task: TaskItem,
- taskID: UUID,
+ item: ItemEntity,
+ itemID: UUID,
index: Int = 0,
- totalTasks: Int = 1,
+ totalItems: Int = 1,
isSelected: Bool,
isDragging: Binding<Bool> = .constant(false),
isSwiping: Binding<Bool> = .constant(false),
- isLastActiveTask: Bool = false,
+ isLastActiveItem: Bool = false,
focusedField: FocusState<FocusField?>.Binding,
- onToggle: @escaping (TaskItem) -> Void,
- onTitleChange: @escaping (TaskItem, String) -> Void,
- onDelete: @escaping (TaskItem) -> Void,
+ 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, _ shouldCreateNewTask: Bool) -> Void = { _, _ in }
+ onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }
) {
- self.task = task
- self.taskID = taskID
+ self.item = item
+ self.itemID = itemID
self.index = index
- self.totalTasks = totalTasks
+ self.totalItems = totalItems
self.isSelected = isSelected
_isDragging = isDragging
_isSwiping = isSwiping
- self.isLastActiveTask = isLastActiveTask
+ self.isLastActiveItem = isLastActiveItem
self.onToggle = onToggle
self.onTitleChange = onTitleChange
self.onDelete = onDelete
@@ -62,14 +62,14 @@ struct TaskRowView: View {
}
var body: some View {
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
+ HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
Button {
- onToggle(task)
+ onToggle(item)
} label: {
// When a right-swipe is past the threshold, preview the toggled state
let previewCompleted = isSwipeTriggered && swipeDirection == .right
- ? !task.isCompleted
- : task.isCompleted
+ ? !item.isCompleted
+ : item.isCompleted
Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
.contentTransition(.identity)
.frame(width: 22, height: 22)
@@ -77,53 +77,53 @@ struct TaskRowView: View {
.font(.system(size: 17))
}
.buttonStyle(.borderless)
- .accessibilityIdentifier("task-checkbox")
- .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
+ .accessibilityIdentifier("item-checkbox")
+ .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
- if !task.isCompleted && (isSelected || isEditing) {
+ if !item.isCompleted && (isSelected || isEditing) {
TappableTextField(
text: $editingTitle,
- isCompleted: task.isCompleted,
+ isCompleted: item.isCompleted,
isDragging: isDragging,
- onEditingChanged: { editing, shouldCreateNewTask in
+ onEditingChanged: { editing, shouldCreateNewItem in
DispatchQueue.main.async {
isCurrentlyEditing = editing
- if editing { onStartEdit(taskID) }
+ if editing { onStartEdit(itemID) }
else {
tapPoint = nil
- onEndEdit(taskID, shouldCreateNewTask)
+ onEndEdit(itemID, shouldCreateNewItem)
}
}
},
- returnKeyType: isLastActiveTask && !editingTitle.isEmpty ? .next : .done,
+ returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done,
onContentChange: { newTitle in
- guard !task.isCompleted else { return }
- onTitleChange(task, newTitle)
+ guard !item.isCompleted else { return }
+ onTitleChange(item, newTitle)
},
- uiAccessibilityIdentifier: "task-text-\(taskID.uuidString)",
+ uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)",
initialCursorPoint: tapPoint
)
- .focused($focusedField, equals: .task(taskID))
+ .focused($focusedField, equals: .item(itemID))
.frame(maxWidth: .infinity, alignment: .leading)
- } else if !task.isCompleted {
- taskProxy
+ } else if !item.isCompleted {
+ itemProxy
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.gesture(SpatialTapGesture().onEnded { value in
tapPoint = value.location
- onSelect(taskID)
- focusedField = .task(taskID)
+ onSelect(itemID)
+ focusedField = .item(itemID)
})
} else {
- taskProxy
+ itemProxy
.frame(maxWidth: .infinity, alignment: .leading)
}
}
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
+ .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
+ .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
.padding(
.leading,
- task.isCompleted ? TaskRowMetrics.completedLeadingPadding : TaskRowMetrics.activeLeadingPadding
+ item.isCompleted ? ItemRowMetrics.completedLeadingPadding : ItemRowMetrics.activeLeadingPadding
)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
@@ -132,34 +132,34 @@ struct TaskRowView: View {
// 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,
+ if item.isCompleted,
let field = focusedField,
- case .task(let id) = field,
- id != taskID
+ case .item(let id) = field,
+ id != itemID
{
return
}
- if task.isCompleted {
- withAnimation { onToggle(task) }
+ if item.isCompleted {
+ withAnimation { onToggle(item) }
} else {
tapPoint = nil
- onSelect(taskID)
- focusedField = .task(taskID)
+ onSelect(itemID)
+ focusedField = .item(itemID)
}
}
.background(cardBackground)
.overlay(alignment: .leading) {
- if !task.isCompleted {
+ if !item.isCompleted {
Rectangle()
.fill(cachedAccentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
+ .frame(width: ItemRowMetrics.accentBarWidth)
}
}
.onAppear {
- editingTitle = task.title
+ editingTitle = item.title
cachedAccentColor = computeAccentColor()
}
- .onChange(of: task.title) { _, newValue in
+ .onChange(of: item.title) { _, newValue in
if !isCurrentlyEditing {
editingTitle = newValue
}
@@ -167,22 +167,22 @@ struct TaskRowView: View {
.onChange(of: index) { _, _ in
cachedAccentColor = computeAccentColor()
}
- .onChange(of: totalTasks) { _, _ in
+ .onChange(of: totalItems) { _, _ in
cachedAccentColor = computeAccentColor()
}
.onChange(of: colorThemeRaw) { _, _ in
cachedAccentColor = computeAccentColor()
}
- .taskSwipeGesture(
+ .itemSwipeGesture(
isDragging: $isDragging,
- isEditing: focusedField == .task(taskID),
+ isEditing: focusedField == .item(itemID),
isSwiping: $isSwiping,
swipeOffset: $swipeOffset,
swipeDirection: $swipeDirection,
isTriggered: $isSwipeTriggered,
completeColor: cachedAccentColor,
- onComplete: { onToggle(task) },
- onDelete: { onDelete(task) }
+ onComplete: { onToggle(item) },
+ onDelete: { onDelete(item) }
)
.onChange(of: isDragging) { _, newValue in
if newValue {
@@ -191,49 +191,49 @@ struct TaskRowView: View {
isSwipeTriggered = false
}
}
- .clipShape(TaskCardModifier.shape)
+ .clipShape(ItemCardModifier.shape)
.overlay(
- isSelected && !task.isCompleted
- ? TaskCardModifier.shape
+ isSelected && !item.isCompleted
+ ? ItemCardModifier.shape
.strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
: nil
)
}
private var isEditing: Bool {
- focusedField == .task(taskID)
+ focusedField == .item(itemID)
}
@ViewBuilder
- private var taskProxy: some View {
- if task.isCompleted {
+ private var itemProxy: some View {
+ if item.isCompleted {
Text(editingTitle)
- .font(TaskRowMetrics.bodySUI)
+ .font(ItemRowMetrics.bodySUI)
.foregroundStyle(.secondary)
.strikethrough(true, color: .secondary)
- .accessibilityIdentifier("task-text-\(taskID.uuidString)")
+ .accessibilityIdentifier("item-text-\(itemID.uuidString)")
} else {
Text(editingTitle)
- .font(TaskRowMetrics.bodySUI)
+ .font(ItemRowMetrics.bodySUI)
.foregroundStyle(.primary)
- .accessibilityIdentifier("task-text-\(taskID.uuidString)")
+ .accessibilityIdentifier("item-text-\(itemID.uuidString)")
}
}
@MainActor
private func computeAccentColor() -> Color {
- guard !task.isCompleted else { return .clear }
- return cachedTaskColor(forIndex: index, total: totalTasks, theme: colorTheme)
+ guard !item.isCompleted else { return .clear }
+ return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
}
@ViewBuilder
private var cardBackground: some View {
- if task.isCompleted {
+ if item.isCompleted {
isSelected ? Color.completedSelected : Color.clear
} else if isSelected {
- Color.taskCard.overlay(cachedAccentColor.opacity(0.15))
+ Color.itemCard.overlay(cachedAccentColor.opacity(0.15))
} else {
- Color.taskCard
+ 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/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)
- }
-}