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:
@@ -71,8 +71,8 @@ Plans:
|
|||||||
**Plans:** 2 plans
|
**Plans:** 2 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
|
- [x] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
|
||||||
- [ ] 03-02-PLAN.md - Add determinism tests for flattening behavior
|
- [x] 03-02-PLAN.md - Add determinism tests for flattening behavior
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- FLAT-01: Visual flattening sorts by sortOrder within each day
|
- FLAT-01: Visual flattening sorts by sortOrder within each day
|
||||||
@@ -92,6 +92,12 @@ Plans:
|
|||||||
|
|
||||||
**Dependencies:** Phase 3 (flattening provides row-to-semantic translation)
|
**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:**
|
**Requirements:**
|
||||||
- DRAG-01: Lift animation on grab (shadow + slight scale)
|
- DRAG-01: Lift animation on grab (shadow + slight scale)
|
||||||
- DRAG-02: Insertion line appears between items showing drop target
|
- DRAG-02: Insertion line appears between items showing drop target
|
||||||
@@ -117,10 +123,10 @@ Plans:
|
|||||||
|-------|--------|--------------|-----------|
|
|-------|--------|--------------|-----------|
|
||||||
| 1 - Semantic Position Model | Complete | 8 | 8 |
|
| 1 - Semantic Position Model | Complete | 8 | 8 |
|
||||||
| 2 - Constraint Validation | Complete | 4 | 4 |
|
| 2 - Constraint Validation | Complete | 4 | 4 |
|
||||||
| 3 - Visual Flattening | In Progress | 3 | 0 |
|
| 3 - Visual Flattening | Complete | 3 | 3 |
|
||||||
| 4 - Drag Interaction | Not Started | 8 | 0 |
|
| 4 - Drag Interaction | Planned | 8 | 0 |
|
||||||
|
|
||||||
**Total:** 12/23 requirements completed
|
**Total:** 15/23 requirements completed
|
||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-01-18*
|
*Roadmap created: 2026-01-18*
|
||||||
|
|||||||
220
.planning/phases/04-drag-interaction/04-01-PLAN.md
Normal file
220
.planning/phases/04-drag-interaction/04-01-PLAN.md
Normal 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>
|
||||||
362
.planning/phases/04-drag-interaction/04-02-PLAN.md
Normal file
362
.planning/phases/04-drag-interaction/04-02-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user