listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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:
MAGENTS.md | 60++++++++++++++++++++++++++++++------------------------------
MListless.xcodeproj/project.pbxproj | 280++++++++++++++++++++++++++++++++++++++++----------------------------------------
AListless/Extensions/ItemListView+Logic.swift | 657+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Extensions/ItemListView+SyncUI.swift | 40++++++++++++++++++++++++++++++++++++++++
DListless/Extensions/TaskListView+Logic.swift | 657-------------------------------------------------------------------------------
DListless/Extensions/TaskListView+SyncUI.swift | 40----------------------------------------
MListless/Helpers/AccentColor.swift | 18+++++++++---------
AListless/Helpers/ItemListTypes.swift | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Helpers/ItemListViewProtocol.swift | 17+++++++++++++++++
DListless/Helpers/TaskListTypes.swift | 199-------------------------------------------------------------------------------
DListless/Helpers/TaskListViewProtocol.swift | 17-----------------
AListless/Models/ItemEntity.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Models/ItemStore.swift | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListless/Models/TaskItem.swift | 48------------------------------------------------
DListless/Models/TaskStore.swift | 201-------------------------------------------------------------------------------
MListless/Sync/CloudKitErrorClassifier.swift | 2+-
MListless/Sync/PersistenceController.swift | 10+++++-----
AListlessMac/Extensions/ItemListView+Toolbar.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessMac/Extensions/TaskListView+Toolbar.swift | 53-----------------------------------------------------
MListlessMac/Helpers/AppColors.swift | 2+-
MListlessMac/Helpers/AppCommands.swift | 40++++++++++++++++++++--------------------
MListlessMac/Helpers/ClickableTextField.swift | 26+++++++++++++-------------
AListlessMac/Helpers/ItemRowDragGesture.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessMac/Helpers/TaskRowDragGesture.swift | 160-------------------------------------------------------------------------------
MListlessMac/ListlessMacApp.swift | 58+++++++++++++++++++++++++++++-----------------------------
AListlessMac/Views/ItemListView.swift | 488+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessMac/Views/ItemRowView.swift | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessMac/Views/TaskListView.swift | 488-------------------------------------------------------------------------------
DListlessMac/Views/TaskRowView.swift | 214-------------------------------------------------------------------------------
MListlessWatch/ListlessWatchApp.swift | 4++--
AListlessWatch/Views/ItemListView.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessWatch/Views/ItemRowView.swift | 44++++++++++++++++++++++++++++++++++++++++++++
DListlessWatch/Views/TaskListView.swift | 72------------------------------------------------------------------------
DListlessWatch/Views/TaskRowView.swift | 44--------------------------------------------
AListlessiOS/Extensions/ItemListView+Drag.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Extensions/ItemListView+NavigationHeader.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Extensions/ItemListView+PullGestures.swift | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Extensions/ItemListView+PullToClear.swift | 9+++++++++
AListlessiOS/Extensions/ItemListView+PullToCreate.swift | 26++++++++++++++++++++++++++
AListlessiOS/Extensions/ItemListView+Toolbar.swift | 10++++++++++
AListlessiOS/Extensions/ItemListView+Undo.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessiOS/Extensions/TaskListView+Drag.swift | 52----------------------------------------------------
DListlessiOS/Extensions/TaskListView+NavigationHeader.swift | 48------------------------------------------------
DListlessiOS/Extensions/TaskListView+PullGestures.swift | 184-------------------------------------------------------------------------------
DListlessiOS/Extensions/TaskListView+PullToClear.swift | 9---------
DListlessiOS/Extensions/TaskListView+PullToCreate.swift | 26--------------------------
DListlessiOS/Extensions/TaskListView+Toolbar.swift | 10----------
DListlessiOS/Extensions/TaskListView+Undo.swift | 62--------------------------------------------------------------
MListlessiOS/Helpers/AppColors.swift | 4++--
MListlessiOS/Helpers/AppCommands.swift | 16++++++++--------
AListlessiOS/Helpers/ItemCardModifier.swift | 34++++++++++++++++++++++++++++++++++
AListlessiOS/Helpers/ItemRowDragGesture.swift | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Helpers/ItemRowMetrics.swift | 27+++++++++++++++++++++++++++
AListlessiOS/Helpers/ItemRowSwipeGesture.swift | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Helpers/KeyCommandBridge.swift | 12++++++------
MListlessiOS/Helpers/TappableTextField.swift | 22+++++++++++-----------
DListlessiOS/Helpers/TaskCardModifier.swift | 34----------------------------------
DListlessiOS/Helpers/TaskRowDragGesture.swift | 105-------------------------------------------------------------------------------
DListlessiOS/Helpers/TaskRowMetrics.swift | 27---------------------------
DListlessiOS/Helpers/TaskRowSwipeGesture.swift | 282-------------------------------------------------------------------------------
MListlessiOS/ListlessiOSApp.swift | 8++++----
MListlessiOS/Views/DraftRowView.swift | 14+++++++-------
AListlessiOS/Views/ItemListView.swift | 588+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/ItemRowView.swift | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/PullToClear.swift | 4++--
MListlessiOS/Views/PullToCreate.swift | 20++++++++++----------
MListlessiOS/Views/SettingsView.swift | 6+++---
DListlessiOS/Views/TaskListView.swift | 588-------------------------------------------------------------------------------
DListlessiOS/Views/TaskRowView.swift | 239-------------------------------------------------------------------------------
MTests/Support/TestHelpers.swift | 24++++++++++++------------
MTests/UI/ListlessMacUITests.swift | 254++++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/UI/ListlessiOSUITests.swift | 86++++++++++++++++++++++++++++++++++++++++----------------------------------------
ATests/Unit/ItemStoreCompletionTests.swift | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ItemStoreEdgeCaseTests.swift | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ItemStoreOrderingTests.swift | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/ItemStoreTests.swift | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTests/Unit/TaskStoreCompletionTests.swift | 189-------------------------------------------------------------------------------
DTests/Unit/TaskStoreEdgeCaseTests.swift | 285-------------------------------------------------------------------------------
DTests/Unit/TaskStoreOrderingTests.swift | 190-------------------------------------------------------------------------------
DTests/Unit/TaskStoreTests.swift | 208-------------------------------------------------------------------------------
80 files changed, 5216 insertions(+), 5216 deletions(-)

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