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