From 23788a44d2de6be87196089f134f4a5ae45f0935 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 18 Jan 2026 16:39:06 -0600 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 16 +- .../phases/04-drag-interaction/04-01-PLAN.md | 220 +++++++++++ .../phases/04-drag-interaction/04-02-PLAN.md | 362 ++++++++++++++++++ 3 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/04-drag-interaction/04-01-PLAN.md create mode 100644 .planning/phases/04-drag-interaction/04-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7fcf942..2bfb44e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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* diff --git a/.planning/phases/04-drag-interaction/04-01-PLAN.md b/.planning/phases/04-drag-interaction/04-01-PLAN.md new file mode 100644 index 0000000..6f77b6d --- /dev/null +++ b/.planning/phases/04-drag-interaction/04-01-PLAN.md @@ -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" +--- + + +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. + + + +@~/.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 +@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +@SportsTime/Core/Models/Domain/ItineraryConstraints.swift +@SportsTime/Core/Theme/Theme.swift + + + + + + Task 1: Migrate to Modern Drag-Drop Delegates + SportsTime/Features/Trip/Views/ItineraryTableViewController.swift + +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. + + +Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` + + +ItineraryTableViewController conforms to UITableViewDragDelegate and UITableViewDropDelegate with all required methods implemented. + + + + + Task 2: Implement Lift Animation with Scale, Shadow, and Tilt + SportsTime/Features/Trip/Views/ItineraryTableViewController.swift + +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 + + +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 + + +Dragged items show lift animation with scale (1.025x), shadow, and tilt (2 degrees) on grab, and settle animation on drop. + + + + + Task 3: Add Haptic Feedback for Grab and Drop + SportsTime/Features/Trip/Views/ItineraryTableViewController.swift + +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. + + +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 + + +Light haptic fires on grab, medium haptic fires on successful drop. Zone transition haptics continue working for valid/invalid zone crossing. + + + + + + +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) + + + +- 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 + + + +After completion, create `.planning/phases/04-drag-interaction/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-drag-interaction/04-02-PLAN.md b/.planning/phases/04-drag-interaction/04-02-PLAN.md new file mode 100644 index 0000000..cbcbe8d --- /dev/null +++ b/.planning/phases/04-drag-interaction/04-02-PLAN.md @@ -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" +--- + + +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) + + + +After completion, create `.planning/phases/04-drag-interaction/04-02-SUMMARY.md` +