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 '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/targeted_waiting_overlay.dart'; /// Searching screen, also responsible for routing all downstream pairing /// transitions: /// /// - PairingTargetedWaitingData → render the targeted waiting overlay above /// the searching shell (the customer sees the 20s countdown + cancel CTA). /// - PairingTargetedUnavailableData → show the bestie-unavailable dialog /// (intermediate; payment stays confirmed; offers fallback-to-blast). /// - PairingFailedData → terminal; route to no-bestie screen. /// - PairingBestieFoundData → existing transition to bestie-found screen. /// - PairingCancelledData → customer cancelled; back home. /// /// Per project memory ("Riverpod ref.listen in build is unsafe"), we use /// ref.listenManual in initState for one-shot side effects rather than /// build-scoped listeners. class SearchingScreen extends ConsumerStatefulWidget { const SearchingScreen({super.key}); @override ConsumerState createState() => _SearchingScreenState(); } class _SearchingScreenState extends ConsumerState { /// Guard against re-firing the bestie-unavailable dialog if the notifier /// briefly emits multiple intermediate states (e.g. WS event arrives just /// after a 409 already opened the dialog). bool _unavailableDialogShown = false; @override void initState() { super.initState(); ref.listenManual(pairingProvider, _onPairingState); // The pairing state can already be PairingTargetedUnavailableData by // the time we mount (the payment screen awaits startTargetedSearch // before navigating; a 409 lands while we're still on the previous // screen). Inspect once after first frame to handle that case. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _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) { // Direct route into the active chat — happens after the brief "found" // animation if the user is already on this screen. context.go('/chat/session/${next.sessionId}', extra: next.mitraName); return; } if (next is PairingFailedData) { // Terminal — payment_session is failed_pairing. context.go('/chat/no-bestie'); return; } if (next is PairingCancelledData) { context.go('/home'); return; } if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) { _unavailableDialogShown = true; // ignore: discarded_futures BestieUnavailableDialog.show( context, paymentSessionId: next.paymentSessionId, mitraName: next.mitraName, topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _unavailableDialogShown = false; }); return; } if (next is PairingErrorData) { // Inline error UX is preferred over SnackBars (project memory: // "Avoid SnackBars for provider errors"). The build below renders // a banner when the state is PairingErrorData. } } @override Widget build(BuildContext context) { final pairingState = ref.watch(pairingProvider); return Scaffold( 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 isTargetedWaiting = state is PairingTargetedWaitingData; final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const SizedBox(height: 32), Text( isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), const Text( 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', textAlign: TextAlign.center, style: TextStyle(fontSize: 16, color: Colors.grey), ), if (errorMessage != null) ...[ const SizedBox(height: 24), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade200), ), child: Text( errorMessage, style: TextStyle(color: Colors.red.shade900), textAlign: TextAlign.center, ), ), ], const SizedBox(height: 48), // The targeted-waiting overlay owns its own cancel button — only // show the general cancel CTA when we're in a non-overlay state. if (!isTargetedWaiting) OutlinedButton( onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(), child: const Text('Batalkan'), ), ], ), ), ); } }