docs(04): create phase 4 drag interaction plans

Phase 04: Drag Interaction
- 2 plans in 2 waves
- Plan 01: Migrate to modern drag-drop delegates, lift animation, haptics
- Plan 02: Themed insertion line, invalid zone feedback, snap-back animation

Ready for execution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-18 16:39:06 -06:00
parent 812c7e631d
commit 23788a44d2
3 changed files with 593 additions and 5 deletions

View File

@@ -71,8 +71,8 @@ Plans:
**Plans:** 2 plans
Plans:
- [ ] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
- [ ] 03-02-PLAN.md - Add determinism tests for flattening behavior
- [x] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
- [x] 03-02-PLAN.md - Add determinism tests for flattening behavior
**Requirements:**
- FLAT-01: Visual flattening sorts by sortOrder within each day
@@ -92,6 +92,12 @@ Plans:
**Dependencies:** Phase 3 (flattening provides row-to-semantic translation)
**Plans:** 2 plans
Plans:
- [ ] 04-01-PLAN.md - Migrate to modern drag-drop delegates with lift animation and haptics
- [ ] 04-02-PLAN.md - Add themed insertion line, invalid zone feedback, and snap-back animation
**Requirements:**
- DRAG-01: Lift animation on grab (shadow + slight scale)
- DRAG-02: Insertion line appears between items showing drop target
@@ -117,10 +123,10 @@ Plans:
|-------|--------|--------------|-----------|
| 1 - Semantic Position Model | Complete | 8 | 8 |
| 2 - Constraint Validation | Complete | 4 | 4 |
| 3 - Visual Flattening | In Progress | 3 | 0 |
| 4 - Drag Interaction | Not Started | 8 | 0 |
| 3 - Visual Flattening | Complete | 3 | 3 |
| 4 - Drag Interaction | Planned | 8 | 0 |
**Total:** 12/23 requirements completed
**Total:** 15/23 requirements completed
---
*Roadmap created: 2026-01-18*

View File

@@ -0,0 +1,220 @@
---
phase: 04-drag-interaction
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
autonomous: true
must_haves:
truths:
- "User sees item lift with subtle scale and shadow when grabbed"
- "User feels light haptic on grab"
- "User feels medium haptic on successful drop"
- "Items can only be dropped in constraint-valid positions"
artifacts:
- path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
provides: "UITableViewDragDelegate and UITableViewDropDelegate implementation"
contains: "UITableViewDragDelegate"
key_links:
- from: "ItineraryTableViewController"
to: "ItineraryConstraints.isValidPosition"
via: "dropSessionDidUpdate validation"
pattern: "constraints\\.isValidPosition"
---
<objective>
Migrate from legacy reordering methods to modern UITableViewDragDelegate/UITableViewDropDelegate and implement core drag interactions: lift animation with scale/shadow/tilt, grab/drop haptics, and constraint-aware drop proposals.
Purpose: Modern drag-drop delegates unlock custom drag previews and precise drop validation. This establishes the foundation for Phase 4's rich visual feedback.
Output: ItineraryTableViewController with working modern drag-drop that feels responsive with lift animation and haptic feedback.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-drag-interaction/04-CONTEXT.md
@.planning/phases/04-drag-interaction/04-RESEARCH.md
@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
@SportsTime/Core/Models/Domain/ItineraryConstraints.swift
@SportsTime/Core/Theme/Theme.swift
</context>
<tasks>
<task type="auto">
<name>Task 1: Migrate to Modern Drag-Drop Delegates</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Replace the legacy reordering approach with modern UITableViewDragDelegate and UITableViewDropDelegate:
1. In `setupTableView()`:
- Add `tableView.dragDelegate = self`
- Add `tableView.dropDelegate = self`
- Add `tableView.dragInteractionEnabled = true` (required on iPhone)
- Keep `isEditing = true` for visual consistency
2. Create `DragContext` class to store drag state:
```swift
private class DragContext {
let item: ItineraryRowItem
let sourceIndexPath: IndexPath
let originalFrame: CGRect
var snapshot: UIView?
init(item: ItineraryRowItem, sourceIndexPath: IndexPath, originalFrame: CGRect) {
self.item = item
self.sourceIndexPath = sourceIndexPath
self.originalFrame = originalFrame
}
}
```
3. Add extension conforming to `UITableViewDragDelegate`:
- `itemsForBeginning session:at:` - Return empty array for non-reorderable items (games, headers). For reorderable items, create UIDragItem with NSItemProvider, store DragContext in session.localContext, call existing `beginDrag(at:)`.
- `dragPreviewParametersForRowAt:` - Return UIDragPreviewParameters with rounded corners (cornerRadius: 12).
- `dragSessionWillBegin:` - Trigger grab haptic via existing feedbackGenerator.
4. Add extension conforming to `UITableViewDropDelegate`:
- `performDropWith coordinator:` - Extract source and destination from coordinator. Call existing move logic (flatItems remove/insert). Call `endDrag()`. Animate drop with `coordinator.drop(item.dragItem, toRowAt:)`.
- `dropSessionDidUpdate session:withDestinationIndexPath:` - Use existing `computeValidDestinationRowsProposed()` and `nearestValue()` logic to validate position. Return `.move` with `.insertAtDestinationIndexPath` for valid, `.forbidden` for invalid.
- `dropSessionDidEnd:` - Call `endDrag()` to clean up.
5. Keep existing `canMoveRowAt` and `moveRowAt` methods as fallback but the new delegates should take precedence.
Key: Reuse existing constraint validation logic (computeValidDestinationRowsProposed, nearestValue, checkZoneTransition). The delegates wire into that existing infrastructure.
</action>
<verify>
Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
</verify>
<done>
ItineraryTableViewController conforms to UITableViewDragDelegate and UITableViewDropDelegate with all required methods implemented.
</done>
</task>
<task type="auto">
<name>Task 2: Implement Lift Animation with Scale, Shadow, and Tilt</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Add custom lift animation when drag begins per CONTEXT.md decisions (iOS Reminders style, quick/snappy, 1.02-1.03x scale, 2-3 degree tilt):
1. Add private helper method `createLiftedSnapshot(for cell:) -> UIView`:
- Create snapshot using `cell.snapshotView(afterScreenUpdates: false)`
- Set frame to cell.frame
- Apply CATransform3D with:
- Perspective: `transform.m34 = -1.0 / 500.0`
- Scale: 1.025 (middle of 1.02-1.03 range)
- Tilt: 2.0 degrees around Y axis (`CATransform3DRotate(transform, 2.0 * .pi / 180.0, 0, 1, 0)`)
- Add shadow: offset (0, 8), radius 16, opacity 0.25, masksToBounds = false
- Return configured snapshot
2. Add private helper method `animateLift(for cell:, snapshot:)`:
- Start snapshot with identity transform and shadowOpacity 0
- Use UIView.animate with duration 0.15, spring damping 0.85, velocity 0.5
- Animate to lifted transform and shadowOpacity 0.25
- Set `cell.alpha = 0` to hide original during drag
3. In `dragSessionWillBegin:`:
- Retrieve DragContext from session.localContext
- Get cell for source indexPath
- Create snapshot, add to tableView.superview
- Animate lift
- Store snapshot in context
4. Add `animateDrop(snapshot:, to destination:)` for drop animation:
- Animate snapshot back to identity transform
- Fade out shadowOpacity
- Remove snapshot on completion
- Restore cell.alpha = 1
Key timing values per CONTEXT.md:
- Lift: 0.15s spring
- Drop: 0.2s spring with damping 0.8
</action>
<verify>
Run on simulator and verify:
1. Drag a travel or custom item
2. Item lifts with visible scale and shadow
3. Slight 3D tilt is visible
4. Drop settles smoothly
</verify>
<done>
Dragged items show lift animation with scale (1.025x), shadow, and tilt (2 degrees) on grab, and settle animation on drop.
</done>
</task>
<task type="auto">
<name>Task 3: Add Haptic Feedback for Grab and Drop</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Enhance haptic feedback to match CONTEXT.md decisions (light on grab, medium on drop):
1. The controller already has `feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)`. Add a second generator:
```swift
private let lightHaptic = UIImpactFeedbackGenerator(style: .light)
private let mediumHaptic = UIImpactFeedbackGenerator(style: .medium)
```
2. In `dragSessionWillBegin:`:
- Call `lightHaptic.prepare()` just before drag starts
- Call `lightHaptic.impactOccurred()` when drag begins
3. In `performDropWith:` (successful drop):
- Call `mediumHaptic.impactOccurred()` after drop animation starts
4. Ensure prepare() is called proactively:
- In `itemsForBeginning:at:` before returning items, call `lightHaptic.prepare()` and `mediumHaptic.prepare()` to reduce haptic latency
5. Keep existing zone transition haptics in `checkZoneTransition()` for entering/leaving valid zones.
Note: The existing feedbackGenerator can be replaced or kept for backward compatibility with existing code paths.
</action>
<verify>
Test on physical device (simulator has no haptics):
1. Grab item - feel light tap
2. Drop item - feel medium tap
3. Drag over invalid zone - feel warning haptic
</verify>
<done>
Light haptic fires on grab, medium haptic fires on successful drop. Zone transition haptics continue working for valid/invalid zone crossing.
</done>
</task>
</tasks>
<verification>
After completing all tasks:
1. Build succeeds without warnings
2. Drag a travel item:
- Lifts with scale, shadow, tilt
- Light haptic on grab
- Can only drop in valid day range
- Medium haptic on drop
3. Drag a custom item:
- Same lift behavior
- Can drop anywhere except on headers
4. Try to drag a game or header:
- No drag interaction (returns empty array)
5. Existing constraint validation still works (invalid positions clamped to nearest valid)
</verification>
<success_criteria>
- DRAG-01: Lift animation on grab (shadow + slight scale) - IMPLEMENTED
- DRAG-06: Haptic feedback on grab (light) and drop (medium) - IMPLEMENTED
- DRAG-08: Slight tilt during drag (2-3 degrees) - IMPLEMENTED
- Foundation for DRAG-02 through DRAG-05 established via delegate pattern
</success_criteria>
<output>
After completion, create `.planning/phases/04-drag-interaction/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,362 @@
---
phase: 04-drag-interaction
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
autonomous: false
must_haves:
truths:
- "User sees themed insertion line between items during drag"
- "Dragged item shows red tint when over invalid zone"
- "Invalid drops snap back to original position with spring animation"
- "User feels triple-tap error haptic on invalid drop"
- "Auto-scroll activates when dragging near viewport edges"
artifacts:
- path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
provides: "InsertionLineView class and invalid drop handling"
contains: "InsertionLineView"
key_links:
- from: "dropSessionDidUpdate"
to: "InsertionLineView.fadeIn/fadeOut"
via: "showInsertionLine/hideInsertionLine methods"
pattern: "insertionLine\\?.fade"
---
<objective>
Add visual polish: themed insertion line showing drop target, red tint feedback for invalid zones, spring snap-back animation for rejected drops, and triple-tap error haptic.
Purpose: Complete the drag-drop UX with clear visual feedback per CONTEXT.md decisions. Users know exactly where items will land and get clear rejection feedback for invalid positions.
Output: Polished drag-drop with insertion line, invalid zone visualization, and smooth rejection animation.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-drag-interaction/04-CONTEXT.md
@.planning/phases/04-drag-interaction/04-RESEARCH.md
@.planning/phases/04-drag-interaction/04-01-SUMMARY.md
@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
@SportsTime/Core/Theme/Theme.swift
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Themed Insertion Line View</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Create a custom insertion line view that follows the user's selected theme per CONTEXT.md (follows theme color, plain line, 150ms fade):
1. Add private class `InsertionLineView: UIView` inside the file:
```swift
private class InsertionLineView: UIView {
private let lineLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
// Plain line, 3pt thickness (middle of 2-4pt range per CONTEXT.md)
lineLayer.lineWidth = 3.0
lineLayer.lineCap = .round
layer.addSublayer(lineLayer)
alpha = 0 // Start hidden
updateThemeColor()
}
func updateThemeColor() {
// Get theme color from Theme.warmOrange (which adapts to current AppTheme)
lineLayer.strokeColor = UIColor(Theme.warmOrange).cgColor
}
override func layoutSubviews() {
super.layoutSubviews()
let path = UIBezierPath()
let margin: CGFloat = 16
path.move(to: CGPoint(x: margin, y: bounds.midY))
path.addLine(to: CGPoint(x: bounds.width - margin, y: bounds.midY))
lineLayer.path = path.cgPath
}
func fadeIn() {
UIView.animate(withDuration: 0.15) { self.alpha = 1.0 }
}
func fadeOut() {
UIView.animate(withDuration: 0.15) { self.alpha = 0 }
}
}
```
2. Add property to controller:
```swift
private var insertionLine: InsertionLineView?
```
3. Add helper methods to show/hide insertion line:
```swift
private func showInsertionLine(at indexPath: IndexPath) {
if insertionLine == nil {
insertionLine = InsertionLineView()
tableView.addSubview(insertionLine!)
}
insertionLine?.updateThemeColor() // Refresh in case theme changed
let rect = tableView.rectForRow(at: indexPath)
insertionLine?.frame = CGRect(
x: 0,
y: rect.minY - 2, // Position above target row
width: tableView.bounds.width,
height: 6 // Slightly larger than line for touch tolerance
)
insertionLine?.fadeIn()
}
private func hideInsertionLine() {
insertionLine?.fadeOut()
}
```
4. Wire into `dropSessionDidUpdate`:
- When position is valid: call `showInsertionLine(at: destinationIndexPath)`
- When position is invalid or no destination: call `hideInsertionLine()`
5. In `dropSessionDidEnd`: call `hideInsertionLine()` and set `insertionLine = nil` to clean up
</action>
<verify>
Build succeeds and run on simulator:
1. Drag an item
2. Insertion line appears between rows in theme color
3. Line fades in/out smoothly as drag moves
4. Line disappears on drop
</verify>
<done>
Themed insertion line appears at drop target position, fades in/out with 150ms animation, uses Theme.warmOrange color.
</done>
</task>
<task type="auto">
<name>Task 2: Implement Invalid Zone Visual Feedback</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Add red tint overlay on dragged item when hovering over invalid zones per CONTEXT.md:
1. Add property to track the current drag snapshot:
```swift
private var currentDragSnapshot: UIView?
```
2. Store snapshot reference in `dragSessionWillBegin`:
```swift
if let context = session.localContext as? DragContext {
currentDragSnapshot = context.snapshot
}
```
3. Add helper methods for invalid tint:
```swift
private func applyInvalidTint() {
guard let snapshot = currentDragSnapshot else { return }
UIView.animate(withDuration: 0.1) {
// Create red overlay on the snapshot
let overlay = snapshot.viewWithTag(999) ?? {
let v = UIView(frame: snapshot.bounds)
v.tag = 999
v.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15)
v.layer.cornerRadius = 12
v.alpha = 0
snapshot.addSubview(v)
return v
}()
overlay.alpha = 1.0
}
}
private func removeInvalidTint() {
guard let snapshot = currentDragSnapshot,
let overlay = snapshot.viewWithTag(999) else { return }
UIView.animate(withDuration: 0.1) {
overlay.alpha = 0
}
}
```
4. In `dropSessionDidUpdate`:
- When returning `.forbidden`: call `applyInvalidTint()`
- When returning `.move`: call `removeInvalidTint()`
5. In `dropSessionDidEnd`: call `removeInvalidTint()` and clear `currentDragSnapshot`
</action>
<verify>
Run on simulator:
1. Drag a travel segment to an invalid day (outside its valid range)
2. Dragged item shows red tint overlay
3. Move back to valid zone - red tint disappears
</verify>
<done>
Dragged items show red tint (systemRed at 15% opacity) when hovering over invalid drop zones, tint fades with 100ms animation.
</done>
</task>
<task type="auto">
<name>Task 3: Implement Snap-Back Animation and Error Haptic</name>
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
<action>
Handle invalid drops with spring snap-back and triple-tap error haptic per CONTEXT.md (~200ms with overshoot, 3 quick taps):
1. Add error haptic generator:
```swift
private let errorHaptic = UINotificationFeedbackGenerator()
```
2. Add triple-tap error method:
```swift
private func playErrorHaptic() {
// First tap - error notification
errorHaptic.notificationOccurred(.error)
// Two additional quick taps for "nope" feeling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
self?.lightHaptic.impactOccurred()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
self?.lightHaptic.impactOccurred()
}
}
```
3. Add snap-back animation method:
```swift
private func performSnapBack(snapshot: UIView, to originalFrame: CGRect, completion: @escaping () -> Void) {
// Per CONTEXT.md: ~200ms with slight overshoot
UIView.animate(
withDuration: 0.2,
delay: 0,
usingSpringWithDamping: 0.7, // Creates overshoot
initialSpringVelocity: 0.3,
options: []
) {
snapshot.layer.transform = CATransform3DIdentity
snapshot.frame = originalFrame
snapshot.layer.shadowOpacity = 0
// Remove red tint
snapshot.viewWithTag(999)?.alpha = 0
} completion: { _ in
snapshot.removeFromSuperview()
completion()
}
}
```
4. Modify `performDropWith coordinator:` to handle invalid drops:
- If destinationIndexPath is nil or validation fails:
- Get DragContext from session
- Call `playErrorHaptic()`
- Call `performSnapBack(snapshot:to:completion:)` with original frame
- In completion: restore source cell alpha to 1, clean up state
- Return early (don't perform the move)
5. Ensure successful drops still use the existing animation path with medium haptic.
6. In `dropSessionDidEnd`: Handle case where drop was cancelled (not just invalid):
- If currentDragSnapshot still exists, perform snap-back
</action>
<verify>
Run on simulator:
1. Drag travel to invalid day
2. Lift finger to drop
3. Item snaps back to original position with spring overshoot
4. On device: triple-tap haptic is felt
</verify>
<done>
Invalid drops trigger snap-back animation (200ms spring with overshoot) and triple-tap error haptic pattern.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete Phase 4 drag-drop implementation:
- Modern UITableViewDragDelegate/UITableViewDropDelegate
- Lift animation with scale (1.025x), shadow, and tilt (2 degrees)
- Themed insertion line between items
- Red tint on invalid zone hover
- Snap-back animation for invalid drops
- Haptic feedback: light grab, medium drop, triple-tap error
- Auto-scroll (built-in with drop delegate)
</what-built>
<how-to-verify>
1. Run on iOS Simulator or Device (device recommended for haptics)
2. Open a saved trip with multiple days and travel segments
3. Test travel segment drag:
- Grab travel - verify lift animation (slight pop, shadow appears)
- Drag to valid day - verify insertion line appears in theme color
- Drag to invalid day (outside valid range) - verify red tint on item
- Drop in invalid zone - verify snap-back animation with overshoot
- Drop in valid zone - verify smooth settle animation
4. Test custom item drag:
- Can move to any day, any position (except directly on header)
- Same lift/drop animations
5. Test games and headers:
- Verify they cannot be dragged (no lift on attempt)
6. Verify insertion line uses current theme color:
- Go to Settings, change theme
- Drag an item - line should use new theme color
On physical device only:
7. Verify haptics:
- Light tap on grab
- Medium tap on successful drop
- Triple-tap "nope" on invalid drop
</how-to-verify>
<resume-signal>Type "approved" if all behaviors work correctly, or describe any issues</resume-signal>
</task>
</tasks>
<verification>
After all tasks including checkpoint:
1. Build succeeds without warnings
2. All 8 DRAG requirements verified:
- DRAG-01: Lift animation (scale + shadow)
- DRAG-02: Insertion line between items
- DRAG-03: Items shuffle (automatic with drop delegate)
- DRAG-04: Magnetic snap on drop (coordinator.drop)
- DRAG-05: Invalid drops rejected with snap-back
- DRAG-06: Haptic on grab (light) and drop (medium)
- DRAG-07: Auto-scroll at viewport edge (built-in)
- DRAG-08: Tilt during drag (2 degrees)
3. Theme-aware insertion line
4. Error handling with red tint + snap-back + triple-tap haptic
</verification>
<success_criteria>
All ROADMAP Phase 4 Success Criteria verified:
1. User sees clear insertion line indicating where item will land during drag
2. Dropping on invalid target snaps item back to original position with haptic feedback
3. Dragging to bottom of visible area auto-scrolls to reveal more content
4. Complete drag-drop cycle feels responsive with visible lift, shuffle, and settle animations
5. Haptic pulses on both grab and drop (verifiable on physical device)
</success_criteria>
<output>
After completion, create `.planning/phases/04-drag-interaction/04-02-SUMMARY.md`
</output>