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 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.
- 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
+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 playbackPositionMs: Int = 0
@Published var playbackCompletionCount = 0
private var playbackEndObserver: NSObjectProtocol?
init() {
self.player = AVPlayer()
self.player.actionAtItemEnd = .pause
}
deinit {
if let playbackEndObserver {
NotificationCenter.default.removeObserver(playbackEndObserver)
}
}
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
player.replaceCurrentItem(with: AVPlayerItem(url: url))
observePlaybackEnd(for: player.currentItem)
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
player.seek(to: startTime)
player.play()
@@ -50,4 +60,26 @@ final class PlayerCoordinator: ObservableObject {
func restart(url: URL, startTimeMs: Int = 0) {
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
}
}
}
}
+60 -36
View File
@@ -11,7 +11,6 @@ struct RoundView: View {
@State private var phase: Phase = .preview
@State private var selectedOutcomeID: String?
@State private var lockAt: Date = .now
@State private var transitionTask: Task<Void, Never>?
@State private var actionMessage: String?
@State private var isSubmitting = false
@State private var swipeFeedback: SwipeFeedback?
@@ -63,6 +62,8 @@ struct RoundView: View {
)
.ignoresSafeArea()
sideSelectionOverlay(round: round)
if let feedback = swipeFeedback {
swipeFeedbackOverlay(feedback)
}
@@ -99,9 +100,15 @@ struct RoundView: View {
phase = .locked
playerCoordinator.pause()
}
.onChange(of: playerCoordinator.playbackCompletionCount) { _, _ in
guard phase == .locked, let round, selectedOutcomeID != nil else {
return
}
startReveal(for: round)
}
}
.onDisappear {
transitionTask?.cancel()
playerCoordinator.pause()
}
}
@@ -131,7 +138,7 @@ struct RoundView: View {
if let round {
switch phase {
case .preview:
swipeHintRow(round: round)
EmptyView()
case .locked:
lockedOverlay(timerLocked: timerLocked)
case .reveal:
@@ -155,32 +162,6 @@ struct RoundView: View {
.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 {
VStack(alignment: .leading, spacing: 10) {
Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label"))
@@ -259,6 +240,52 @@ struct RoundView: View {
.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 {
DragGesture(minimumDistance: 24)
.onChanged { value in
@@ -300,7 +327,6 @@ struct RoundView: View {
}
private func startPreview(_ round: HermesRound) {
transitionTask?.cancel()
phase = .preview
selectedOutcomeID = nil
actionMessage = nil
@@ -397,11 +423,11 @@ struct RoundView: View {
private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) {
phase = .locked
playerCoordinator.play(rate: 2.0)
analytics.track("round_accelerated", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
}
transitionTask?.cancel()
transitionTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_100_000_000)
guard !Task.isCancelled else {
private func startReveal(for round: HermesRound) {
guard let selectedOutcomeID else {
return
}
@@ -409,7 +435,6 @@ struct RoundView: View {
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() {
guard let round = repository.currentRound, let selectedOutcomeID else {
@@ -423,7 +448,6 @@ struct RoundView: View {
private func nextRound() {
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
transitionTask?.cancel()
actionMessage = nil
Task { @MainActor in