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>
363 lines
12 KiB
Markdown
363 lines
12 KiB
Markdown
---
|
|
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>
|