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 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
|
||||
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +423,17 @@ 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 {
|
||||
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 })
|
||||
private func startReveal(for round: HermesRound) {
|
||||
guard let selectedOutcomeID 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 })
|
||||
}
|
||||
|
||||
private func showResult() {
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user