diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index b9b6c90..0076732 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -6,7 +6,9 @@ // test phone numbers or fixed codes into production code paths. import { peekStubOtp } from '../../services/otp.service.js' +import { expirePairingRequest } from '../../services/pairing.service.js' import { getDb } from '../../db/client.js' +import { PairingFailureCause, SessionStatus } from '../../constants.js' const sql = getDb() @@ -77,4 +79,33 @@ export const internalTestRoutes = async (fastify) => { } return { ok: true, ...updated } }) + + // Force-expire a pairing blast (used by Maestro Stage 5 flow to drive the + // searching screen into the timeout state without waiting 5 minutes). Marks + // the most-recently-created blast chat_session as no_mitra_available. + // + // Body shape: + // { session_id: '' } → expire this specific session + // { latest: true } → expire the most-recent SEARCHING session + fastify.post('/force-pairing-timeout', async (request, reply) => { + const { session_id, latest } = request.body ?? {} + let target = session_id + if (latest === true) { + const [row] = await sql` + SELECT id FROM chat_sessions + WHERE status = ${SessionStatus.SEARCHING} + ORDER BY created_at DESC + LIMIT 1 + ` + if (!row) { + return reply.code(404).send({ error: 'no_searching_session' }) + } + target = row.id + } + if (!target) { + return reply.code(400).send({ error: 'session_id or latest:true required in body' }) + } + await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE) + return { ok: true, session_id: target } + }) } diff --git a/client_app/.maestro/flows/05_searching_timeout.yaml b/client_app/.maestro/flows/05_searching_timeout.yaml new file mode 100644 index 0000000..c52aec5 --- /dev/null +++ b/client_app/.maestro/flows/05_searching_timeout.yaml @@ -0,0 +1,89 @@ +# Stage 5 acceptance: drive the searching screen into the 5-min timeout +# state without waiting 5 minutes, verify the new copy + both CTAs render. +# +# Flow: +# home → tap CTA → payment funnel → confirm → /chat/searching → +# force-timeout via dev endpoint → verify timeout panel + CTAs. +# +# Pre-req: +# 1. Customer is already onboarded + on /home (run flow 01 first). +# 2. At least one mitra is ONLINE on the target backend (so the home +# "Mulai Curhat" CTA is enabled — we then force-timeout server-side +# regardless of mitra availability). +# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production' +# (so the _test routes register). +# +# Run: +# maestro test client_app/.maestro/flows/05_searching_timeout.yaml +appId: ${APP_ID_ANDROID} +env: + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +- launchApp: + clearState: false +- assertVisible: "Mulai Curhat" + +# Step 1: enter payment funnel. +- tapOn: "Mulai Curhat" +- extendedWaitUntil: + visible: + text: "pilih cara curhat|sesi pertama|pilih durasi" + timeout: 10000 + +# Step 2: regardless of branch, end up on /payment/method. +- runFlow: + when: + visible: + text: "pilih cara curhat" + commands: + - tapOn: "chat" + - extendedWaitUntil: + visible: + text: "pilih durasi" + timeout: 5000 + - tapOn: + text: "5 menit" + retryTapIfNoChange: true + - tapOn: + text: "bayar" + retryTapIfNoChange: true +- runFlow: + when: + visible: + text: "sesi pertama" + commands: + - tapOn: + text: "mulai" + retryTapIfNoChange: true + +# Step 3: cara-bayar → tap bayar → waiting screen. +- extendedWaitUntil: + visible: + text: "cara bayar" + timeout: 10000 +- tapOn: + text: "bayar" + retryTapIfNoChange: true + +# Step 4: payment confirms via mock; the searching screen opens. The +# soft-prompt copy ships in Stage 5 — we wait for that landmark. +- extendedWaitUntil: + visible: + text: "sambil nunggu" + timeout: 15000 +- assertVisible: "lagi nyari bestie..." + +# Step 5: force the 5-min timeout server-side; the WS event lands within +# ~1s and the screen flips to the timeout panel. +- runScript: + file: ../scripts/force_pairing_timeout.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# Step 6: verify timeout panel + both CTAs render. +- extendedWaitUntil: + visible: + text: "masih nyari nih" + timeout: 10000 +- assertVisible: "coba cari lagi" +- assertVisible: "kembali ke home" diff --git a/client_app/.maestro/scripts/force_pairing_timeout.js b/client_app/.maestro/scripts/force_pairing_timeout.js new file mode 100644 index 0000000..f362fed --- /dev/null +++ b/client_app/.maestro/scripts/force_pairing_timeout.js @@ -0,0 +1,16 @@ +// Force-expire the most-recent searching chat_session by hitting the dev-only +// /internal/_test/force-pairing-timeout endpoint. Used by the Stage 5 maestro +// flow (05_searching_timeout.yaml) to drive the searching screen into the +// timeout state without waiting 5 minutes. +// +// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow). +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const resp = http.post(`${url}/internal/_test/force-pairing-timeout`, { + body: JSON.stringify({ latest: true }), + headers: { 'Content-Type': 'application/json' }, +}) +if (resp.status !== 200) { + throw new Error(`force-pairing-timeout failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.SESSION_ID = data.session_id diff --git a/client_app/lib/features/chat/screens/bestie_found_screen.dart b/client_app/lib/features/chat/screens/bestie_found_screen.dart index b641728..d34e63d 100644 --- a/client_app/lib/features/chat/screens/bestie_found_screen.dart +++ b/client_app/lib/features/chat/screens/bestie_found_screen.dart @@ -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 createState() => _BestieFoundScreenState(); +} + +class _BestieFoundScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + ref.listenManual(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(), ], ), ), diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 1c8d537..2b9320b 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -2,23 +2,30 @@ 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 '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/targeted_waiting_overlay.dart'; -/// Searching screen, also responsible for routing all downstream pairing -/// transitions: +/// Searching screen — Phase 4 Stage 5 reskin of the SSearchPrompt soft-prompt +/// + searching panel. Renders three pairing-driven phases inline: /// -/// - PairingTargetedWaitingData → render the targeted waiting overlay above -/// the searching shell (the customer sees the 20s countdown + cancel CTA). -/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog +/// - `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). -/// - PairingFailedData → terminal; route to no-bestie screen. -/// - PairingBestieFoundData → existing transition to bestie-found screen. -/// - PairingCancelledData → customer cancelled; back home. +/// - `PairingCancelledData` → `/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. +/// 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}); @@ -27,19 +34,12 @@ class SearchingScreen extends ConsumerStatefulWidget { } 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)); @@ -58,18 +58,10 @@ class _SearchingScreenState extends ConsumerState { } 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; @@ -88,12 +80,6 @@ class _SearchingScreenState extends ConsumerState { }); 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 @@ -101,6 +87,7 @@ class _SearchingScreenState extends ConsumerState { final pairingState = ref.watch(pairingProvider); return Scaffold( + backgroundColor: HaloTokens.bg, body: SafeArea( child: Stack( children: [ @@ -120,52 +107,314 @@ class _SearchingBody extends ConsumerWidget { @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; + 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'), + 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: () { + ref.read(pairingProvider.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, ), ), ); diff --git a/client_app/lib/features/chat/screens/targeted_waiting_screen.dart b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart new file mode 100644 index 0000000..50f7316 --- /dev/null +++ b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart @@ -0,0 +1,198 @@ +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'; + +/// Phase 4 Stage 5 — `SWaitingBestie` overlay. +/// +/// Entry route: `/chat/waiting-targeted/:mitraId` — pushed from the chat +/// history "Curhat lagi" CTA after the targeted payment session is confirmed. +/// +/// Three sub-states mapped from `pairingProvider`: +/// +/// - `waiting` (PairingTargetedWaitingData) — orb + 20s countdown + cancel. +/// The countdown is purely cosmetic; the server owns the auto-reject timer. +/// - `accepted` (PairingBestieFoundData / PairingActiveData) — routes into +/// the chat screen immediately. +/// - `declined` (PairingTargetedUnavailableData) — shows the bestie-offline +/// popup. TODO(stage8): swap this stub for the proper BestieOfflinePopup +/// component once Stage 8 lands. +class TargetedWaitingScreen extends ConsumerStatefulWidget { + final String mitraId; + const TargetedWaitingScreen({super.key, required this.mitraId}); + + @override + ConsumerState createState() => + _TargetedWaitingScreenState(); +} + +class _TargetedWaitingScreenState extends ConsumerState { + bool _popupShown = false; + + @override + void initState() { + super.initState(); + ref.listenManual(pairingProvider, _onPairingState); + 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/session/${next.sessionId}', extra: 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 && !_popupShown) { + _popupShown = true; + // TODO(stage8): replace stub with the production BestieOfflinePopup + // (Stage 8 owns the proper variant + fallback-to-blast surface). + // ignore: discarded_futures + HaloPopup.show( + context, + title: '${next.mitraName} lagi nggak online', + body: + 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.', + primary: HaloPopupAction( + label: 'kembali ke home', + onPressed: () { + ref.read(pairingProvider.notifier).reset(); + if (mounted) context.go('/home'); + }, + ), + ).then((_) { + if (mounted) _popupShown = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(pairingProvider); + + final waiting = state is PairingTargetedWaitingData ? state : null; + final mitraName = waiting?.mitraName ?? 'bestie'; + final secondsRemaining = waiting?.secondsRemaining ?? 0; + + return PopScope( + // Targeted-wait is reachable directly from chat history; per the + // deep-link pop-fallback rule (project memory), we drop the user + // back to home if they swipe back rather than into a stale stack. + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + ref.read(pairingProvider.notifier).cancelSearch(); + }, + child: 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: [ + HaloOrb( + size: 120, + seed: mitraName.hashCode, + label: mitraName, + ), + const SizedBox(height: HaloSpacing.s20), + const Text( + '◦ MENUNGGU JAWABAN ◦', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.6, + color: HaloTokens.brand, + ), + ), + const SizedBox(height: HaloSpacing.s8), + Text( + 'lagi nungguin $mitraName', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 24, + height: 30 / 24, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + const SizedBox(height: HaloSpacing.s12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s8, + ), + decoration: BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: HaloTokens.brandSoft), + ), + child: Text( + '${secondsRemaining}d', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ), + const SizedBox(height: HaloSpacing.s12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'kalau bestie nggak respon dalam 20 detik, kami bantu cariin yang lain.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + height: 20 / 13, + color: HaloTokens.inkSoft, + ), + ), + ), + ], + ), + ), + ), + HaloButton( + label: 'batalkan', + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: () => + ref.read(pairingProvider.notifier).cancelSearch(), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/payment_screen.dart b/client_app/lib/features/payment/screens/payment_screen.dart index 15eca7c..88132fd 100644 --- a/client_app/lib/features/payment/screens/payment_screen.dart +++ b/client_app/lib/features/payment/screens/payment_screen.dart @@ -112,7 +112,14 @@ class _PaymentScreenState extends ConsumerState { if (!mounted) return; // Reset our local notifier so a future payment attempt starts clean. ref.read(paymentProvider.notifier).reset(); - context.go('/chat/searching'); + // Phase 4 Stage 5: targeted "Curhat lagi" lands on the dedicated + // SWaitingBestie overlay screen; general blast still uses the searching + // shell (which renders inline soft-prompt + timeout panels). + if (payment.targetedMitraId != null) { + context.go('/chat/waiting-targeted/${payment.targetedMitraId}'); + } else { + context.go('/chat/searching'); + } } @override diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 536e93a..60d7efc 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -21,6 +21,7 @@ import 'features/chat/screens/no_bestie_screen.dart'; import 'features/chat/screens/chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; +import 'features/chat/screens/targeted_waiting_screen.dart'; import 'features/payment/screens/payment_screen.dart'; import 'features/payment/screens/payment_entry_screen.dart'; import 'features/payment/screens/discount_paywall_screen.dart'; @@ -210,6 +211,15 @@ GoRouter buildRouter(Ref ref) { ); }), GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()), + // Phase 4 Stage 5 — targeted "Curhat lagi" 20s wait overlay. Pushed + // after a confirmed targeted payment session; the pairing notifier + // emits PairingTargetedWaitingData synchronously after the POST. + GoRoute( + path: '/chat/waiting-targeted/:mitraId', + builder: (context, state) => TargetedWaitingScreen( + mitraId: state.pathParameters['mitraId']!, + ), + ), GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { final extra = state.extra; final mitraName = extra is String