Files
SportstimeAPI/.planning/phases/04-drag-interaction/04-02-PLAN.md
Trey t 23788a44d2 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>
2026-01-18 16:39:06 -06:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
04-drag-interaction 02 execute 2
04-01
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
false
truths artifacts key_links
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
path provides contains
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift InsertionLineView class and invalid drop handling InsertionLineView
from to via pattern
dropSessionDidUpdate InsertionLineView.fadeIn/fadeOut showInsertionLine/hideInsertionLine methods 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.

<execution_context> @/.claude/get-shit-done/workflows/execute-plan.md @/.claude/get-shit-done/templates/summary.md </execution_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 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:

    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:

    private var insertionLine: InsertionLineView?
    
  3. Add helper methods to show/hide insertion line:

    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:

  6. Drag an item

  7. Insertion line appears between rows in theme color

  8. Line fades in/out smoothly as drag moves

  9. 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:

    private var currentDragSnapshot: UIView?
    
  2. Store snapshot reference in dragSessionWillBegin:

    if let context = session.localContext as? DragContext {
        currentDragSnapshot = context.snapshot
    }
    
  3. Add helper methods for invalid tint:

    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:

  6. Drag a travel segment to an invalid day (outside its valid range)

  7. Dragged item shows red tint overlay

  8. 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:

    private let errorHaptic = UINotificationFeedbackGenerator()
    
  2. Add triple-tap error method:

    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:

    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:
  7. Drag travel to invalid day

  8. Lift finger to drop

  9. Item snaps back to original position with spring overshoot

  10. 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

<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>
After completion, create `.planning/phases/04-drag-interaction/04-02-SUMMARY.md`