|
| 1 | +# Subtask Management & UI Polish — Spec |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Four improvements to TaskMenu: |
| 6 | +1. **Inline "Add subtask"** from the task list (no detail view required) |
| 7 | +2. **Indent/Outdent** to convert tasks ↔ subtasks via context menu and keyboard |
| 8 | +3. **Hover highlight** — subtle row highlight on mouse hover |
| 9 | +4. **Completion animation** — satisfying visual feedback when checking off a task |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## 1. Inline "Add Subtask" |
| 14 | + |
| 15 | +### Behavior |
| 16 | +- In the **context menu** for any root-level task (i.e. tasks where `parent == nil`), add an "Add Subtask" action |
| 17 | +- Clicking it inserts a **temporary inline text field** directly below the task (indented to child level), with focus |
| 18 | +- Pressing Enter creates the subtask via `appState.addSubtask(title:parentId:)` and dismisses the field |
| 19 | +- Pressing Escape cancels and dismisses the field |
| 20 | +- If the parent was collapsed, auto-expand it first |
| 21 | + |
| 22 | +### Implementation |
| 23 | + |
| 24 | +**TaskListView.swift:** |
| 25 | +- Add `@State private var inlineSubtaskParentID: String?` to track which task is getting an inline subtask |
| 26 | +- In the context menu for task rows where `task.parent == nil`, add: |
| 27 | + ``` |
| 28 | + Button { inlineSubtaskParentID = task.id } label: { |
| 29 | + Label("Add Subtask", systemImage: "text.badge.plus") |
| 30 | + } |
| 31 | + ``` |
| 32 | +- When rendering the flattened task list, after a task whose `id == inlineSubtaskParentID`, insert an inline text field row at `indentLevel + 1` |
| 33 | +- Auto-expand: if `inlineSubtaskParentID` is set and the task is collapsed, call `appState.toggleCollapsed(taskID)` to expand it |
| 34 | + |
| 35 | +**New: InlineSubtaskField view (can be a private struct in TaskListView.swift):** |
| 36 | +- Simple HStack: plus.circle icon + TextField |
| 37 | +- `@FocusState` auto-focused on appear |
| 38 | +- On submit: call `appState.addSubtask(title:parentId:)`, then keep the field open for rapid entry (clear text, stay focused) |
| 39 | +- On Escape (via `.onExitCommand`): set `inlineSubtaskParentID = nil` |
| 40 | +- On click outside / loss of focus: dismiss (set `inlineSubtaskParentID = nil`) |
| 41 | +- Indented to match child level: `paddingLeading = (parentIndentLevel + 1) * 20` |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## 2. Indent / Outdent |
| 46 | + |
| 47 | +### Behavior |
| 48 | +- **Indent** (Tab or context menu "Make Subtask"): Convert a root-level task into a subtask of the task directly above it in the list |
| 49 | + - Only available if the task above exists and is a root-level task (no nested subtask-of-subtask — Google Tasks only supports one level of nesting) |
| 50 | + - Uses the `moveTask` API with `parentId` set to the task above |
| 51 | +- **Outdent** (Shift+Tab or context menu "Move to Top Level"): Convert a subtask back to a root-level task |
| 52 | + - Uses the `moveTask` API with `parentId` omitted (moves to top level) |
| 53 | + - Position: place it after its former parent in the root list |
| 54 | + |
| 55 | +### Implementation |
| 56 | + |
| 57 | +**AppState.swift — new methods:** |
| 58 | +```swift |
| 59 | +func indentTask(_ task: TaskItem) async { |
| 60 | + // Find the task directly above in the root tasks list |
| 61 | + // Call api.moveTask(listId:taskId:parentId:) to make it a child |
| 62 | + // Update local tasks array |
| 63 | +} |
| 64 | + |
| 65 | +func outdentTask(_ task: TaskItem) async { |
| 66 | + // Call api.moveTask(listId:taskId:previousId:) with no parent |
| 67 | + // previousId = the former parent's ID (to place it right after) |
| 68 | + // Update local tasks array |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +**Context menu additions (TaskListView.swift or TaskRowView.swift):** |
| 73 | +- For root-level tasks that have a sibling above them: "Make Subtask" (indent) |
| 74 | +- For subtasks (tasks with `parent != nil`): "Move to Top Level" (outdent) |
| 75 | + |
| 76 | +**Keyboard shortcuts:** Deferred — context menu only for now. |
| 77 | + |
| 78 | +### Constraints |
| 79 | +- Google Tasks only supports **one level** of nesting. Do NOT allow indenting a subtask further (making a sub-subtask) |
| 80 | +- Indent is only available for incomplete root-level tasks |
| 81 | +- Outdent is only available for subtasks |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## 3. Hover Highlight |
| 86 | + |
| 87 | +### Current State |
| 88 | +`TaskRowView` already has `@State private var isHovering` and applies a background: |
| 89 | +```swift |
| 90 | +.background( |
| 91 | + RoundedRectangle(cornerRadius: 6) |
| 92 | + .fill(isHovering ? Color.primary.opacity(0.05) : .clear) |
| 93 | +) |
| 94 | +``` |
| 95 | + |
| 96 | +### Changes |
| 97 | +- Increase opacity slightly: `0.05` → `0.06` (subtle but more visible) |
| 98 | +- Show context actions on hover: display a small trailing "..." button (or action icons) that appear on hover. This gives discoverability for the "Add Subtask" action without requiring right-click |
| 99 | + - The hover actions area appears at the trailing edge of the row |
| 100 | + - Contains: a `+` button (add subtask, only for root tasks) and optionally `⋯` for more actions |
| 101 | + - Fades in/out with the hover state |
| 102 | + |
| 103 | +### Implementation |
| 104 | +- In `TaskRowView`, add a trailing HStack that's only visible when `isHovering` |
| 105 | +- Pass new callbacks: `onAddSubtask: (() -> Void)?` (nil for subtasks) |
| 106 | +- The `+` button calls `onAddSubtask` |
| 107 | +- Use `.opacity(isHovering ? 1 : 0)` with animation for smooth fade |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## 4. Completion Animation |
| 112 | + |
| 113 | +### Behavior |
| 114 | +When a user checks off a task: |
| 115 | +1. The circle fills with a **green checkmark** with a brief scale-up bounce (already partially there with `.contentTransition(.symbolEffect(.replace))`) |
| 116 | +2. The title gets a **strikethrough that animates left-to-right** (or just appears) |
| 117 | +3. After a **short delay (~600ms)**, the row **slides out to the right and fades**, then moves to the completed section |
| 118 | +4. If the task has subtasks, they gray out together with the parent (already works) |
| 119 | + |
| 120 | +### Implementation |
| 121 | + |
| 122 | +**TaskRowView.swift:** |
| 123 | +- Add `@State private var justCompleted = false` |
| 124 | +- When `onToggle` is called and the task is going from incomplete → complete: |
| 125 | + - Set `justCompleted = true` |
| 126 | + - The checkmark circle gets a brief `.scaleEffect` pulse: scale to 1.2 then back to 1.0 |
| 127 | +- The existing `contentTransition(.symbolEffect(.replace))` on the checkmark icon handles the symbol swap animation already — keep that |
| 128 | + |
| 129 | +**TaskListView.swift:** |
| 130 | +- The transition is already defined: |
| 131 | + ```swift |
| 132 | + .transition(.asymmetric( |
| 133 | + insertion: .move(edge: .top).combined(with: .opacity), |
| 134 | + removal: .move(edge: .trailing).combined(with: .opacity) |
| 135 | + )) |
| 136 | + ``` |
| 137 | + This should already animate the row sliding right when it moves from active to completed section. Verify this works correctly with the current animation block. If it doesn't trigger, we may need to add a brief delay before the optimistic update in `toggleTask` to let the animation play. |
| 138 | + |
| 139 | +--- |
| 140 | + |
| 141 | +## Files to Modify |
| 142 | + |
| 143 | +| File | Changes | |
| 144 | +|------|---------| |
| 145 | +| `TaskRowView.swift` | Hover action buttons, completion animation (scale pulse), add `onAddSubtask` callback | |
| 146 | +| `TaskListView.swift` | Inline subtask field, context menu indent/outdent/add-subtask, keyboard shortcuts | |
| 147 | +| `AppState.swift` | `indentTask()`, `outdentTask()` methods | |
| 148 | + |
| 149 | +## Files to Create |
| 150 | + |
| 151 | +None — all changes fit in existing files. |
| 152 | + |
| 153 | +--- |
| 154 | + |
| 155 | +## Testing Notes |
| 156 | + |
| 157 | +- Test indent when there's only one root task (should be disabled — no task above to become parent of) |
| 158 | +- Test outdent places the task after its former parent |
| 159 | +- Test that indenting a subtask is not possible (one-level limit) |
| 160 | +- Test inline subtask field dismissal on Escape and focus loss |
| 161 | +- Test completion animation plays before the row moves to completed section |
| 162 | +- Test drag-and-drop still works with the new hover actions |
0 commit comments