diff --git a/PLAN.md b/PLAN.md index d4b2454..9cfbd5c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/docs/mobile-fullscreen-round-flow.md b/docs/mobile-fullscreen-round-flow.md new file mode 100644 index 0000000..b65b264 --- /dev/null +++ b/docs/mobile-fullscreen-round-flow.md @@ -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 diff --git a/example-reveal.mov b/example-reveal.mov new file mode 100644 index 0000000..82d11c6 Binary files /dev/null and b/example-reveal.mov differ diff --git a/example-round.mov b/example-round.mov new file mode 100644 index 0000000..d0ee533 Binary files /dev/null and b/example-round.mov differ diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift index cf7b2b0..0bc258e 100644 --- a/mobile/ios-app/Core/Media/PlayerCoordinator.swift +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -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 + } + } + } } diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift index 8ae1303..e1c51c9 100644 --- a/mobile/ios-app/Features/Round/RoundView.swift +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -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? @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