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>
159 lines
5.8 KiB
Dart
159 lines
5.8 KiB
Dart
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';
|
|
|
|
/// 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;
|
|
|
|
const BestieFoundScreen({
|
|
super.key,
|
|
required this.sessionId,
|
|
required this.mitraName,
|
|
});
|
|
|
|
@override
|
|
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(
|
|
backgroundColor: HaloTokens.bg,
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
HaloButton(
|
|
label: ctaLabel,
|
|
fullWidth: true,
|
|
size: HaloButtonSize.lg,
|
|
onPressed: _enterChat,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|