---
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"
---
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.
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
@.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
Task 1: Create Themed Insertion Line View
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
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
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
Themed insertion line appears at drop target position, fades in/out with 150ms animation, uses Theme.warmOrange color.
Task 2: Implement Invalid Zone Visual Feedback
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
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`
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
Dragged items show red tint (systemRed at 15% opacity) when hovering over invalid drop zones, tint fades with 100ms animation.
Task 3: Implement Snap-Back Animation and Error Haptic
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
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
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
Invalid drops trigger snap-back animation (200ms spring with overshoot) and triple-tap error haptic pattern.
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)
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
Type "approved" if all behaviors work correctly, or describe any issues
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
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)