import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; import '../../payment/state/payment_draft_provider.dart'; import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/targeted_waiting_overlay.dart'; /// Searching screen — Phase 4 Stage 5 reskin of the SSearchPrompt soft-prompt /// + searching panel. Renders three pairing-driven phases inline: /// /// - `PairingSearchingData` → reflective-prompt cards + pulsing-dots panel. /// - `PairingFailedData` (cause = noMitraAvailable / allMitrasRejected / /// targetedMitraTimeout / targetedMitraRejected — i.e. the 5-minute blast /// timeout) → moon panel + `coba cari lagi` / `kembali ke home` CTAs. /// - `PairingTargetedWaitingData` → 20s targeted-wait overlay above the body. /// /// Other transitions still route away as before: /// /// - `PairingBestieFoundData` → `/chat/found` (S9 Match screen). /// - `PairingActiveData` → `/chat/session/:id`. /// - `PairingTargetedUnavailableData` → bestie-unavailable dialog overlay /// (intermediate; payment stays confirmed; offers fallback-to-blast). /// - `PairingCancelledData` → `/home`. /// /// Per project memory ("Riverpod ref.listen in build is unsafe"), one-shot /// transitions are wired through `ref.listenManual` in initState. class SearchingScreen extends ConsumerStatefulWidget { const SearchingScreen({super.key}); @override ConsumerState createState() => _SearchingScreenState(); } class _SearchingScreenState extends ConsumerState { bool _unavailableDialogShown = false; @override void initState() { super.initState(); ref.listenManual(pairingProvider, _onPairingState); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; // Kick off pairing if it hasn't started yet — Phase 4's multi-screen // payment flow lands here without an upstream startSearch call // (waiting → notif-gate → /chat/searching, no intermediate that // owned the call). Branch on draft.targetedMitraId: a returning-user // "Curhat lagi" flow stamped the targeted mitra onto the draft before // payment, so we fire the targeted request and bounce to the dedicated // wait overlay; everything else is a general blast. // // Carry-over guard: if a previous chat session ended, pairingProvider // retains its terminal state (PairingActiveData with the *old* // sessionId, PairingFailed, PairingCancelled, etc). Without resetting // here, the `state is PairingInitialData` branch wouldn't fire and // `_onPairingState` below would re-emit a stale PairingActiveData → // /chat/session/, dropping the customer on a chat // screen for a `completed` session ("Sesi sudah berakhir"). Reset to // Initial whenever we have a fresh payment to consume. final draft = ref.read(paymentDraftNotifierProvider); var state = ref.read(pairingProvider); if (state is! PairingInitialData && draft.paymentId != null) { ref.read(pairingProvider.notifier).reset(); state = ref.read(pairingProvider); } if (state is PairingInitialData) { if (draft.paymentId != null) { if (draft.targetedMitraId != null) { // ignore: discarded_futures ref.read(pairingProvider.notifier).startTargetedSearch( paymentRequestId: draft.paymentId!, mitraId: draft.targetedMitraId!, mitraName: draft.targetedMitraName ?? 'Bestie', topicSensitivity: draft.topicSensitivity, ); context.go('/chat/waiting-targeted/${draft.targetedMitraId}'); return; } // ignore: discarded_futures ref.read(pairingProvider.notifier).startSearch( paymentRequestId: draft.paymentId!, topicSensitivity: draft.topicSensitivity, ); } } _onPairingState(null, ref.read(pairingProvider)); }); } void _onPairingState(PairingData? prev, PairingData next) { if (!mounted) return; if (next is PairingBestieFoundData) { context.go('/chat/found', extra: { 'sessionId': next.sessionId, 'mitraName': next.mitraName, }); return; } if (next is PairingActiveData) { context.go('/chat/session/${next.sessionId}', extra: next.mitraName); return; } if (next is PairingCancelledData) { context.go('/home'); return; } if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) { _unavailableDialogShown = true; // ignore: discarded_futures BestieOfflinePopup.show( context, variant: BestieOfflineVariant.returning, mitraName: next.mitraName, paymentRequestId: next.paymentRequestId, topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _unavailableDialogShown = false; }); return; } } @override Widget build(BuildContext context) { final pairingState = ref.watch(pairingProvider); return Scaffold( backgroundColor: HaloTokens.bg, body: SafeArea( child: Stack( children: [ _SearchingBody(state: pairingState), if (pairingState is PairingTargetedWaitingData) TargetedWaitingOverlay(waiting: pairingState), ], ), ), ); } } class _SearchingBody extends ConsumerWidget { final PairingData state; const _SearchingBody({required this.state}); @override Widget build(BuildContext context, WidgetRef ref) { final isTimeout = state is PairingFailedData; final isTargetedWaiting = state is PairingTargetedWaitingData; final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null; return Padding( padding: const EdgeInsets.fromLTRB( HaloSpacing.s24, HaloSpacing.s24, HaloSpacing.s24, HaloSpacing.s32, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'sambil nunggu, coba pikirin sebentar 🤍', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 24, height: 30 / 24, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: -0.4, ), ), SizedBox(height: HaloSpacing.s8), Text( 'gausah dipikirin formatnya. ngalir aja gimana enaknya buat kamu.', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14, height: 22 / 14, color: HaloTokens.inkSoft, ), ), SizedBox(height: HaloSpacing.s20), _PromptCard('apa yang lagi paling kamu rasain hari ini?'), SizedBox(height: HaloSpacing.s8), _PromptCard('kapan terakhir kamu ngerasa lega?'), SizedBox(height: HaloSpacing.s8), _PromptCard('ada satu hal yang pengen banget kamu cerita...'), ], ), ), ), if (errorMessage != null) ...[ const SizedBox(height: HaloSpacing.s16), _ErrorBanner(message: errorMessage), ], const SizedBox(height: HaloSpacing.s16), isTimeout ? const _TimeoutPanel() : _SearchingPanel(targetedWaiting: isTargetedWaiting), if (isTimeout) ...[ const SizedBox(height: HaloSpacing.s12), HaloButton( label: 'coba cari lagi', fullWidth: true, size: HaloButtonSize.lg, onPressed: () { final notifier = ref.read(pairingProvider.notifier); final s = state; if (s is PairingFailedData && s.isRetryable) { notifier.retryBlast(); } else { notifier.reset(); context.go('/payment/entry'); } }, ), const SizedBox(height: HaloSpacing.s8), HaloButton( label: 'kembali ke home', variant: HaloButtonVariant.ghost, fullWidth: true, onPressed: () { ref.read(pairingProvider.notifier).reset(); context.go('/home'); }, ), ] else if (!isTargetedWaiting) ...[ const SizedBox(height: HaloSpacing.s12), HaloButton( label: 'batalkan', variant: HaloButtonVariant.ghost, fullWidth: true, onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(), ), ], ], ), ); } } class _PromptCard extends StatelessWidget { final String text; const _PromptCard(this.text); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(HaloSpacing.s16), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: HaloTokens.border), ), child: Text( text, style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, height: 20 / 13, color: HaloTokens.ink, ), ), ); } } class _SearchingPanel extends StatelessWidget { final bool targetedWaiting; const _SearchingPanel({required this.targetedWaiting}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(HaloSpacing.s20), decoration: BoxDecoration( color: HaloTokens.brandSofter, borderRadius: BorderRadius.circular(18), border: Border.all(color: HaloTokens.brandSoft), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const _PulsingDots(), const SizedBox(width: HaloSpacing.s16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( targetedWaiting ? 'menghubungi bestie...' : 'lagi nyari bestie...', style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, fontWeight: FontWeight.w600, color: HaloTokens.brandDark, ), ), const SizedBox(height: 2), const Text( 'biasanya 30 detik · sambil baca prompt aja', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 11.5, color: HaloTokens.inkSoft, ), ), ], ), ), ], ), ); } } class _TimeoutPanel extends StatelessWidget { const _TimeoutPanel(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(HaloSpacing.s20), decoration: BoxDecoration( color: HaloTokens.brandSofter, borderRadius: BorderRadius.circular(18), border: Border.all(color: HaloTokens.brandSoft), ), child: const Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text('🌙', style: TextStyle(fontSize: 26)), SizedBox(width: HaloSpacing.s16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'masih nyari nih...', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, fontWeight: FontWeight.w600, color: HaloTokens.brandDark, ), ), SizedBox(height: 2), Text( 'bestie lagi rame. coba cari lagi atau kembali nanti', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 11.5, height: 16 / 11.5, color: HaloTokens.inkSoft, ), ), ], ), ), ], ), ); } } class _PulsingDots extends StatefulWidget { const _PulsingDots(); @override State<_PulsingDots> createState() => _PulsingDotsState(); } class _PulsingDotsState extends State<_PulsingDots> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } double _scaleAt(double t, double phase) { // Mirrors the keyframe in v3.jsx: (0,80,100) → 0.6 ; 40 → 1. final shifted = (t - phase) % 1.0; final eased = shifted < 0 ? shifted + 1.0 : shifted; if (eased < 0.4) { return 0.6 + (1.0 - 0.6) * (eased / 0.4); } else if (eased < 0.8) { return 1.0 - (1.0 - 0.6) * ((eased - 0.4) / 0.4); } return 0.6; } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, _) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { final scale = _scaleAt(_controller.value, i * 0.16); return Padding( padding: EdgeInsets.only(right: i == 2 ? 0 : 4), child: Transform.scale( scale: scale, child: Container( width: 8, height: 8, decoration: const BoxDecoration( color: HaloTokens.brand, shape: BoxShape.circle, ), ), ), ); }), ); }, ); } } class _ErrorBanner extends StatelessWidget { final String message; const _ErrorBanner({required this.message}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( color: const Color(0x14D86B6B), borderRadius: BorderRadius.circular(12), border: Border.all(color: HaloTokens.danger.withValues(alpha: 0.4)), ), child: Text( message, textAlign: TextAlign.center, style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, color: HaloTokens.danger, ), ), ); } }