refine iOS swipe flow interaction
This commit is contained in:
@@ -26,6 +26,7 @@ This is the canonical working plan and progress log for the project. Use this fi
|
|||||||
- Android local unit tests now cover localization, error mapping, and analytics batch conversion, and `./gradlew testDebugUnitTest` passes with the Android SDK installed.
|
- Android local unit tests now cover localization, error mapping, and analytics batch conversion, and `./gradlew testDebugUnitTest` passes with the Android SDK installed.
|
||||||
- Android swipe-down gesture handling now uses cumulative drag distance, and edge-case tests cover upward drags, slow swipes, and per-gesture single-trigger behavior.
|
- Android swipe-down gesture handling now uses cumulative drag distance, and edge-case tests cover upward drags, slow swipes, and per-gesture single-trigger behavior.
|
||||||
- iOS now defaults to a demo test mode with mock rounds, and the primary round flow is a fullscreen swipe-driven video experience with a 15-second lock timer and accelerated reveal transition.
|
- iOS now defaults to a demo test mode with mock rounds, and the primary round flow is a fullscreen swipe-driven video experience with a 15-second lock timer and accelerated reveal transition.
|
||||||
|
- The fullscreen round interaction contract is now documented in `docs/mobile-fullscreen-round-flow.md`, including demo-mode rules, side-tint swipe feedback, and the requirement to finish the accelerated round clip before reveal starts.
|
||||||
|
|
||||||
### Still Open
|
### Still Open
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Mobile Fullscreen Round Flow
|
||||||
|
|
||||||
|
This document is the interaction contract for the TikTok-style round flow.
|
||||||
|
|
||||||
|
## Entry
|
||||||
|
|
||||||
|
- App opens directly into the round experience in `Demo` mode by default.
|
||||||
|
- Demo mode must not require account creation or login.
|
||||||
|
- Demo mode must not rely on backend round selection or reveal transitions.
|
||||||
|
|
||||||
|
## Demo Assets
|
||||||
|
|
||||||
|
- Preview clip: `example-round.mov`
|
||||||
|
- Reveal clip: `example-reveal.mov`
|
||||||
|
- On iOS these files are bundled into the app and used locally.
|
||||||
|
|
||||||
|
## Round Flow
|
||||||
|
|
||||||
|
1. Start fullscreen preview playback immediately.
|
||||||
|
2. Show the event prompt at the top, driven by event type or `questionKey`.
|
||||||
|
3. Show a shrinking half-circle timer based on the preview clip duration.
|
||||||
|
4. Allow swipe anywhere on the screen while preview is active.
|
||||||
|
5. Use Tinder directions:
|
||||||
|
right swipe = first/yes outcome
|
||||||
|
left swipe = second/no outcome
|
||||||
|
6. When dragging, tint the side being chosen:
|
||||||
|
right side = green
|
||||||
|
left side = red
|
||||||
|
7. Do not show bottom swipe buttons or button-like selection panels during preview.
|
||||||
|
8. After selection, do not jump ahead in the preview clip.
|
||||||
|
9. Instead, keep playing the current preview clip at accelerated speed.
|
||||||
|
10. Only when the preview clip finishes should reveal playback start.
|
||||||
|
11. Reveal playback uses the reveal clip and then transitions into the result state.
|
||||||
|
|
||||||
|
## Locking
|
||||||
|
|
||||||
|
- If the timer reaches zero before a selection, freeze the current preview frame.
|
||||||
|
- Locked-without-selection must not auto-reveal.
|
||||||
|
- In demo mode, selection acceptance is local.
|
||||||
|
- In live mode, selection still goes through backend acceptance.
|
||||||
|
|
||||||
|
## Android Follow-Up
|
||||||
|
|
||||||
|
Android should match the iOS behavior exactly:
|
||||||
|
|
||||||
|
- direct demo-mode entry
|
||||||
|
- no backend dependency in demo mode
|
||||||
|
- fullscreen preview surface
|
||||||
|
- timer derived from preview clip length
|
||||||
|
- side tint feedback instead of bottom buttons
|
||||||
|
- accelerated preview continuation after selection
|
||||||
|
- reveal starts only after the preview clip actually ends
|
||||||
Binary file not shown.
Binary file not shown.
@@ -8,14 +8,24 @@ final class PlayerCoordinator: ObservableObject {
|
|||||||
|
|
||||||
@Published var isPlaying = false
|
@Published var isPlaying = false
|
||||||
@Published var playbackPositionMs: Int = 0
|
@Published var playbackPositionMs: Int = 0
|
||||||
|
@Published var playbackCompletionCount = 0
|
||||||
|
|
||||||
|
private var playbackEndObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.player = AVPlayer()
|
self.player = AVPlayer()
|
||||||
self.player.actionAtItemEnd = .pause
|
self.player.actionAtItemEnd = .pause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let playbackEndObserver {
|
||||||
|
NotificationCenter.default.removeObserver(playbackEndObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
|
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
|
||||||
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||||
|
observePlaybackEnd(for: player.currentItem)
|
||||||
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
|
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
|
||||||
player.seek(to: startTime)
|
player.seek(to: startTime)
|
||||||
player.play()
|
player.play()
|
||||||
@@ -50,4 +60,26 @@ final class PlayerCoordinator: ObservableObject {
|
|||||||
func restart(url: URL, startTimeMs: Int = 0) {
|
func restart(url: URL, startTimeMs: Int = 0) {
|
||||||
prepareForPreview(url: url, startTimeMs: startTimeMs)
|
prepareForPreview(url: url, startTimeMs: startTimeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func observePlaybackEnd(for item: AVPlayerItem?) {
|
||||||
|
if let playbackEndObserver {
|
||||||
|
NotificationCenter.default.removeObserver(playbackEndObserver)
|
||||||
|
self.playbackEndObserver = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let item else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackEndObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemDidPlayToEndTime,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.isPlaying = false
|
||||||
|
self?.playbackCompletionCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct RoundView: View {
|
|||||||
@State private var phase: Phase = .preview
|
@State private var phase: Phase = .preview
|
||||||
@State private var selectedOutcomeID: String?
|
@State private var selectedOutcomeID: String?
|
||||||
@State private var lockAt: Date = .now
|
@State private var lockAt: Date = .now
|
||||||
@State private var transitionTask: Task<Void, Never>?
|
|
||||||
@State private var actionMessage: String?
|
@State private var actionMessage: String?
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var swipeFeedback: SwipeFeedback?
|
@State private var swipeFeedback: SwipeFeedback?
|
||||||
@@ -63,6 +62,8 @@ struct RoundView: View {
|
|||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
sideSelectionOverlay(round: round)
|
||||||
|
|
||||||
if let feedback = swipeFeedback {
|
if let feedback = swipeFeedback {
|
||||||
swipeFeedbackOverlay(feedback)
|
swipeFeedbackOverlay(feedback)
|
||||||
}
|
}
|
||||||
@@ -99,9 +100,15 @@ struct RoundView: View {
|
|||||||
phase = .locked
|
phase = .locked
|
||||||
playerCoordinator.pause()
|
playerCoordinator.pause()
|
||||||
}
|
}
|
||||||
|
.onChange(of: playerCoordinator.playbackCompletionCount) { _, _ in
|
||||||
|
guard phase == .locked, let round, selectedOutcomeID != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startReveal(for: round)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
transitionTask?.cancel()
|
|
||||||
playerCoordinator.pause()
|
playerCoordinator.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +138,7 @@ struct RoundView: View {
|
|||||||
if let round {
|
if let round {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .preview:
|
case .preview:
|
||||||
swipeHintRow(round: round)
|
EmptyView()
|
||||||
case .locked:
|
case .locked:
|
||||||
lockedOverlay(timerLocked: timerLocked)
|
lockedOverlay(timerLocked: timerLocked)
|
||||||
case .reveal:
|
case .reveal:
|
||||||
@@ -155,32 +162,6 @@ struct RoundView: View {
|
|||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func swipeHintRow(round: HermesRound) -> some View {
|
|
||||||
let leftOutcome = sortedOutcomes(for: round).dropFirst().first
|
|
||||||
let rightOutcome = sortedOutcomes(for: round).first
|
|
||||||
|
|
||||||
return HStack(spacing: 12) {
|
|
||||||
swipeEdgeLabel(title: leftOutcome.map(outcomeTitle) ?? localization.string(for: "round.no"), alignment: .leading, color: .red)
|
|
||||||
swipeEdgeLabel(title: rightOutcome.map(outcomeTitle) ?? localization.string(for: "round.yes"), alignment: .trailing, color: HermesTheme.positive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func swipeEdgeLabel(title: String, alignment: HorizontalAlignment, color: Color) -> some View {
|
|
||||||
VStack(alignment: alignment, spacing: 6) {
|
|
||||||
Text(title)
|
|
||||||
.font(.title3.weight(.bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
Text(alignment == .leading ? localization.string(for: "round.swipe_left" ) : localization.string(for: "round.swipe_right"))
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.white.opacity(0.72))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .trailing)
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.background(color.opacity(0.18))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func lockedOverlay(timerLocked: Bool) -> some View {
|
private func lockedOverlay(timerLocked: Bool) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label"))
|
Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label"))
|
||||||
@@ -259,6 +240,52 @@ struct RoundView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sideSelectionOverlay(round: HermesRound?) -> some View {
|
||||||
|
if let round, phase == .preview {
|
||||||
|
let dragThreshold: CGFloat = 18
|
||||||
|
let isDraggingRight = dragOffset.width > dragThreshold
|
||||||
|
let isDraggingLeft = dragOffset.width < -dragThreshold
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
sideTint(
|
||||||
|
title: sortedOutcomes(for: round).dropFirst().first.map(outcomeTitle) ?? localization.string(for: "round.no"),
|
||||||
|
subtitle: localization.string(for: "round.swipe_left"),
|
||||||
|
color: .red,
|
||||||
|
opacity: isDraggingLeft ? min(abs(dragOffset.width) / 180.0, 0.78) : 0
|
||||||
|
)
|
||||||
|
sideTint(
|
||||||
|
title: sortedOutcomes(for: round).first.map(outcomeTitle) ?? localization.string(for: "round.yes"),
|
||||||
|
subtitle: localization.string(for: "round.swipe_right"),
|
||||||
|
color: HermesTheme.positive,
|
||||||
|
opacity: isDraggingRight ? min(abs(dragOffset.width) / 180.0, 0.78) : 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sideTint(title: String, subtitle: String, color: Color, opacity: Double) -> some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
color.opacity(opacity)
|
||||||
|
if opacity > 0.05 {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 30, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.88))
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture {
|
private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture {
|
||||||
DragGesture(minimumDistance: 24)
|
DragGesture(minimumDistance: 24)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
@@ -300,7 +327,6 @@ struct RoundView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startPreview(_ round: HermesRound) {
|
private func startPreview(_ round: HermesRound) {
|
||||||
transitionTask?.cancel()
|
|
||||||
phase = .preview
|
phase = .preview
|
||||||
selectedOutcomeID = nil
|
selectedOutcomeID = nil
|
||||||
actionMessage = nil
|
actionMessage = nil
|
||||||
@@ -397,11 +423,11 @@ struct RoundView: View {
|
|||||||
private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) {
|
private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) {
|
||||||
phase = .locked
|
phase = .locked
|
||||||
playerCoordinator.play(rate: 2.0)
|
playerCoordinator.play(rate: 2.0)
|
||||||
|
analytics.track("round_accelerated", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
||||||
|
}
|
||||||
|
|
||||||
transitionTask?.cancel()
|
private func startReveal(for round: HermesRound) {
|
||||||
transitionTask = Task { @MainActor in
|
guard let selectedOutcomeID else {
|
||||||
try? await Task.sleep(nanoseconds: 1_100_000_000)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +435,6 @@ struct RoundView: View {
|
|||||||
playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0)
|
playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0)
|
||||||
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func showResult() {
|
private func showResult() {
|
||||||
guard let round = repository.currentRound, let selectedOutcomeID else {
|
guard let round = repository.currentRound, let selectedOutcomeID else {
|
||||||
@@ -423,7 +448,6 @@ struct RoundView: View {
|
|||||||
|
|
||||||
private func nextRound() {
|
private func nextRound() {
|
||||||
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
|
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
|
||||||
transitionTask?.cancel()
|
|
||||||
actionMessage = nil
|
actionMessage = nil
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
Reference in New Issue
Block a user