refine iOS swipe flow interaction

This commit is contained in:
2026-04-10 11:25:42 +02:00
parent a4d77078a4
commit 392116bf28
6 changed files with 150 additions and 41 deletions
+1
View File
@@ -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
+52
View File
@@ -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.
BIN
View File
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
}
}
}
} }
+65 -41
View File
@@ -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,18 +423,17 @@ 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) return
guard !Task.isCancelled else {
return
}
phase = .reveal
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 })
} }
phase = .reveal
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 })
} }
private func showResult() { private func showResult() {
@@ -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