Phase 4 Stage 5: pairing UX upgrades (searching + match + targeted-wait)

Searching screen: soft-prompt card reskin, pulsing-dots panel replaces
the spinner, inline 5-min timeout panel with `coba cari lagi` (resets
pairing notifier + routes to /payment/entry for a fresh funnel — the
server-side payment is failed_pairing at that point so a stale retry
isn't valid) and `kembali ke home` ghost CTA.

Bestie-found screen: S9 Match-V4 reskin — HaloOrb + status dot +
'halo, aku bestie {name}' + `mulai sesi {N} menit →` with N pulled from
the active session's duration_minutes.

Targeted-wait overlay (new) at /chat/waiting-targeted/:mitraId. Three
sub-states from pairingProvider's PairingTargetedWaitingData:
waiting (20s countdown) / accepted (routes to chat) / declined (stubbed
BestieOfflinePopup with a TODO pointing to Stage 8). Reached via
payment_screen._routeToSearchOnConfirmed when the confirm carried a
targetedMitraId — keeps the mandatory payment-before-pairing invariant.

Dev-only POST /internal/_test/force-pairing-timeout drives the 5-min
timeout shortcut for the Maestro flow without waiting live.

Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:49:07 +08:00
parent 7ae8f33b2c
commit f170d54535
8 changed files with 800 additions and 93 deletions

View File

@@ -1,9 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class BestieFoundScreen extends ConsumerWidget {
/// Phase 4 Stage 5 — S9 Match-found screen.
///
/// Reskinned from the v4 mock (`v4.jsx::S9MatchV4`). Shows the matched
/// bestie's orb + a small online status dot, the matched-line copy, and a
/// primary CTA `mulai sesi {N} menit →`. The duration is read from the active
/// session payload (which the pairing notifier kicks via
/// `activeSessionProvider.refresh()` on the WS `paired` event).
///
/// `PairingActiveData` is the auto-advance signal — fired by the notifier
/// ~2s after WS `paired` lands. The same advance is also reachable manually
/// via the CTA in case the user is faster than the auto-advance timer.
class BestieFoundScreen extends ConsumerStatefulWidget {
final String sessionId;
final String mitraName;
@@ -14,34 +28,127 @@ class BestieFoundScreen extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(pairingProvider, (prev, next) {
ConsumerState<BestieFoundScreen> createState() => _BestieFoundScreenState();
}
class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override
void initState() {
super.initState();
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
if (!mounted) return;
if (next is PairingActiveData) {
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
}
});
}
void _enterChat() {
context.go('/chat/session/${widget.sessionId}', extra: widget.mitraName);
}
@override
Widget build(BuildContext context) {
final activeSession = ref.watch(activeSessionProvider).valueOrNull;
final durationMinutes =
activeSession?.session?['duration_minutes'] as int?;
final ctaLabel = durationMinutes != null
? 'mulai sesi $durationMinutes menit →'
: 'mulai sesi →';
final subtitle = durationMinutes != null
? 'siap nemenin kamu $durationMinutes menit ke depan. cerita aja pelan-pelan ya 🤍'
: 'siap nemenin kamu. cerita aja pelan-pelan ya 🤍';
return Scaffold(
body: Center(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.check_circle, size: 80, color: Colors.green),
const SizedBox(height: 24),
const Text(
'Bestie ditemukan!',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
HaloOrb(
size: 140,
seed: widget.mitraName.hashCode,
label: widget.mitraName,
),
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: HaloTokens.success,
border: Border.all(
color: HaloTokens.bg,
width: 3,
),
),
),
),
],
),
const SizedBox(height: HaloSpacing.s20),
const Text(
'◦ MATCHED ◦',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.6,
color: HaloTokens.brand,
),
),
const SizedBox(height: HaloSpacing.s8),
Text(
'halo, aku bestie ${widget.mitraName}',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 32 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: HaloSpacing.s8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 22 / 14,
color: HaloTokens.inkSoft,
),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(
'Menghubungkan kamu ke $mitraName',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.grey),
HaloButton(
label: ctaLabel,
fullWidth: true,
size: HaloButtonSize.lg,
onPressed: _enterChat,
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
],
),
),