diff --git a/client_app/.maestro/flows/07_end_session_2step.yaml b/client_app/.maestro/flows/07_end_session_2step.yaml new file mode 100644 index 0000000..7ab587f --- /dev/null +++ b/client_app/.maestro/flows/07_end_session_2step.yaml @@ -0,0 +1,76 @@ +# Stage 7 acceptance: customer-initiated end-of-session 2-step flow. +# +# Flow: +# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first). +# 2. Tap "akhiri" in the AppBar → step-1 confirm popup ("yakin mau akhiri sesi?"). +# 3. Tap "lanjut akhiri" → step-2 confirm popup ("mau tinggalin pesan penutup?"). +# 4. Tap "tulis pesan penutup" → closing-message bottom sheet. +# 5. Type a message → "kirim & akhiri sesi" → POSTs goodbye + closes session. +# 6. Verify navigation to S11 thank-you screen ("makasih udah curhat"). +# 7. Tap "balik ke home" → home screen ("Mulai Curhat"). +# +# Pre-req: +# 1. A live chat session is on screen (paired + active). Chain after flow +# 03_payment_to_chat_happy.yaml. +# +# Run (chained): +# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \ +# client_app/.maestro/flows/07_end_session_2step.yaml +appId: ${APP_ID_ANDROID} +--- +- launchApp: + clearState: false + +# Step 0: assert we're on the chat screen. +- extendedWaitUntil: + visible: + text: "Ketik Pesan" + timeout: 10000 + +# Step 1: tap "akhiri" in the AppBar → step-1 popup. +- tapOn: "akhiri" +- extendedWaitUntil: + visible: + text: "yakin mau akhiri sesi?" + timeout: 5000 +- assertVisible: "lanjut akhiri" +- assertVisible: "gak jadi, balik" + +# Step 2: tap "lanjut akhiri" → step-2 popup. +- tapOn: "lanjut akhiri" +- extendedWaitUntil: + visible: + text: "mau tinggalin pesan penutup?" + timeout: 5000 +- assertVisible: "tulis pesan penutup" +- assertVisible: "lewati saja" + +# Step 3: tap "tulis pesan penutup" → closing-message bottom sheet. +- tapOn: "tulis pesan penutup" +- extendedWaitUntil: + visible: + text: "pesan penutup" + timeout: 5000 +- assertVisible: "kirim & akhiri sesi" +- assertVisible: "lewat — langsung akhiri" + +# Step 4: type a message + send. +- tapOn: + text: "makasih ya bestie..." +- inputText: "makasih bestie, sesi ini ngebantu banget" +- hideKeyboard +- tapOn: "kirim & akhiri sesi" + +# Step 5: verify S11 thank-you screen. +- extendedWaitUntil: + visible: + text: "makasih udah curhat" + timeout: 10000 +- assertVisible: "balik ke home" + +# Step 6: tap "balik ke home" → home. +- tapOn: "balik ke home" +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 5000 diff --git a/client_app/lib/core/chat/session_closure_notifier.dart b/client_app/lib/core/chat/session_closure_notifier.dart index 779f9c2..25aeb2c 100644 --- a/client_app/lib/core/chat/session_closure_notifier.dart +++ b/client_app/lib/core/chat/session_closure_notifier.dart @@ -30,6 +30,13 @@ class ClosureCompleteData extends SessionClosureData { const ClosureCompleteData(); } +/// Stage 7 — emitted when the close-session API returns 409 (mitra-rejects- +/// close path). The chat screen surfaces a "bestie offline / returning" +/// fallback popup; Stage 8 will own the proper variant. +class ClosureRejectedByMitraData extends SessionClosureData { + const ClosureRejectedByMitraData(); +} + class ClosureErrorData extends SessionClosureData { final String message; const ClosureErrorData(this.message); @@ -111,4 +118,37 @@ class SessionClosure extends _$SessionClosure { state = const ClosureErrorData('Gagal mengirim pesan penutup.'); } } + + /// Stage 7 — customer-initiated close. Calls + /// `POST /api/client/session/:sessionId/end`. On success, emits + /// `ClosureCompleteData` and refreshes the active-session snapshot so the + /// home CTA flips back to "Mulai Curhat" without waiting for the next poll. + /// On 409, emits `ClosureRejectedByMitraData` so the chat screen can show + /// the bestie-returning fallback popup. Other errors fall back to + /// `ClosureErrorData`. + Future closeSession(String sessionId) async { + try { + await ref.read(apiClientProvider).post( + '/api/client/session/$sessionId/end', + data: const {}, + ); + state = const ClosureCompleteData(); + ref.invalidate(activeSessionProvider); + } on DioException catch (e) { + if (e.response?.statusCode == 409) { + state = const ClosureRejectedByMitraData(); + } else { + final code = e.response?.data?['error']?['code']; + if (code == 'SESSION_NOT_ACTIVE') { + // Server treats it as already closed — equivalent to success. + state = const ClosureCompleteData(); + ref.invalidate(activeSessionProvider); + } else { + state = const ClosureErrorData('Gagal mengakhiri sesi.'); + } + } + } catch (_) { + state = const ClosureErrorData('Gagal mengakhiri sesi.'); + } + } } diff --git a/client_app/lib/core/config/app_config_provider.dart b/client_app/lib/core/config/app_config_provider.dart new file mode 100644 index 0000000..e3c43eb --- /dev/null +++ b/client_app/lib/core/config/app_config_provider.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Phase 4 Stage 7 — UX A/B toggle for the two-step end-session confirm. +/// +/// Backed by `app_config.end_session_two_step_confirm` (seeded `true` in +/// Phase 4 Stage 1.5). The plan mentions an A/B switch but no client-facing +/// endpoint is exposed yet — Stage 1.5 only seeded the row. Until a public +/// `/api/shared/config/app-flags` (or similar) is added, this provider keeps +/// the seed default on-device. When the endpoint lands, swap the override +/// for a `FutureProvider` that fetches it. +/// +/// TODO(phase4-followup): wire to backend once the read-side endpoint is added. +final endSessionTwoStepConfirmProvider = Provider((ref) => true); diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index f35d54f..5ef69ce 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -5,10 +5,15 @@ import 'package:go_router/go_router.dart'; import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/chat_notifier.dart'; import '../../../core/chat/session_closure_notifier.dart'; +import '../../../core/config/app_config_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_popup.dart'; import '../../../core/theme/widgets/halo_snackbar.dart'; import '../widgets/chat_expired_banner.dart'; +import '../widgets/closing_message_sheet.dart'; +import '../widgets/confirm_end_step1.dart'; +import '../widgets/confirm_end_step2.dart'; import '../widgets/pricing_bottom_sheet.dart'; // Chat theme colors @@ -37,7 +42,7 @@ class _ChatScreenState extends ConsumerState { StreamSubscription? _warningSub; bool _showBestieBanner = true; bool _showUserBanner = true; - bool _expiredDialogShown = false; + bool _rejectPopupShown = false; // Per-session-mount idempotency flag for the 3-min snackbar. The backend // also guards once-per-session (timers.threeMinFired), but a fresh mount // could still receive the event on a refreshed status pull, so we belt- @@ -118,53 +123,86 @@ class _ChatScreenState extends ConsumerState { } } - Future _showSessionExpiredDialog() async { - if (_expiredDialogShown) return; - _expiredDialogShown = true; + /// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and + /// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the + /// flag is `true` the user sees step-1 first; when `false` (A/B variant) we + /// jump straight to step-2 (write-message vs skip). + Future _onAkhiriSesiTapped() async { + final twoStep = ref.read(endSessionTwoStepConfirmProvider); + if (!twoStep) { + _showStep2(); + return; + } + await ConfirmEndStep1.show(context, onConfirm: _showStep2); + } + + void _showStep2() { if (!mounted) return; - await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => AlertDialog( - title: const Text('Waktu Curhat Berakhir'), - content: const Text( - 'Sesi curhatmu sudah habis waktunya. Kamu bisa menutup obrolan atau memperpanjang waktu untuk lanjut bicara.', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - _exitChat(); - }, - child: const Text('Tutup'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId); - }, - child: const Text('Perpanjang'), - ), - ], - ), + ConfirmEndStep2.show( + context, + onWriteMessage: _showClosingSheet, + onSkip: _closeWithoutMessage, ); } + void _showClosingSheet() { + if (!mounted) return; + ClosingMessageSheet.show( + context, + sessionId: widget.sessionId, + onCompleted: _goToThankYou, + ); + } + + Future _closeWithoutMessage() async { + await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId); + // Navigation is driven by the closure listener (success path) or the + // ClosureRejectedByMitraData branch (409 fallback popup). + } + + void _goToThankYou() { + if (!mounted) return; + context.go('/chat/thank-you'); + } + + Future _showBestieReturningPopup() async { + if (_rejectPopupShown) return; + _rejectPopupShown = true; + if (!mounted) return; + // TODO(stage8): replace with BestieOfflinePopup variant: 'returning' + await HaloPopup.show( + context, + title: 'bestie lagi balik...', + body: 'sesi belum bisa ditutup karena bestie masih nyaut. coba lagi sebentar ya.', + icon: const Text('🔄', style: TextStyle(fontSize: 40)), + primary: HaloPopupAction(label: 'oke', onPressed: () {}), + ); + _rejectPopupShown = false; + // Reset closure state so the user can retry without a stale-error block. + ref.read(sessionClosureProvider.notifier).reset(); + } + @override Widget build(BuildContext context) { final chatState = ref.watch(chatProvider); final closureState = ref.watch(sessionClosureProvider); - // Listen for closure complete to navigate home + // Stage 7 — closure outcomes drive routing. Success ends in S11 thank-you; + // 409 surfaces the bestie-returning fallback popup (Stage 8 owns the + // dedicated component). ref.listen(sessionClosureProvider, (prev, next) { if (next is ClosureCompleteData) { - // Make doubly sure home picks up the cleared session. ref.invalidate(activeSessionProvider); - context.go('/home'); + _goToThankYou(); + } else if (next is ClosureRejectedByMitraData) { + _showBestieReturningPopup(); } }); - // Listen for chat state changes to manage closure state and timer-expired modal + // Listen for chat state changes to manage closure state. Stage 7 removed + // the legacy `_showSessionExpiredDialog` modal — the Stage 6 ChatExpiredBanner + // is the in-place replacement, and the user reaches the closing flow via + // the AppBar "akhiri" button. ref.listen(chatProvider, (prev, next) { if (next is ChatConnectedData) { // Early-end (mitra/customer ended before timer): show goodbye composer. @@ -174,19 +212,11 @@ class _ChatScreenState extends ConsumerState { ref.read(sessionClosureProvider.notifier).declineExtension(); } } - // Timer-expired: show non-dismissible modal once on false→true flip. - final wasExpired = prev is ChatConnectedData && prev.sessionExpired; - if (next.sessionExpired && !wasExpired) { - _showSessionExpiredDialog(); - } if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) { final closure = ref.read(sessionClosureProvider); if (closure is! ClosureInitialData) { ref.read(sessionClosureProvider.notifier).reset(); } - // If we're back to a healthy active state, allow the modal to fire - // again on a later expiry (e.g. after extension then re-expiry). - _expiredDialogShown = false; } _scrollToBottom(); final unread = next.messages @@ -240,9 +270,23 @@ class _ChatScreenState extends ConsumerState { actions: [ if (chatState is ChatConnectedData && remainingTick != null) Padding( - padding: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.only(right: 4), child: Center(child: _buildTimerPill(remainingTick)), ), + if (chatState is ChatConnectedData && + !chatState.sessionClosing) + TextButton( + onPressed: _onAkhiriSesiTapped, + style: TextButton.styleFrom( + foregroundColor: HaloTokens.brandDark, + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('akhiri'), + ), ], ), body: _buildBody(chatState, closureState, remainingTick), @@ -509,6 +553,10 @@ class _ChatScreenState extends ConsumerState { ); } + // TODO(phase4-followup): Stage 7 moved the customer-initiated goodbye flow + // to ClosingMessageSheet. This inline composer is still reachable when the + // mitra ends a session early (sessionClosing fired by the server). Migrate + // that path to the new sheet too once the early-end UX is finalised. Widget _buildGoodbyeView(SessionClosureData closureState) { return SingleChildScrollView( padding: const EdgeInsets.all(32), diff --git a/client_app/lib/features/chat/screens/thank_you_screen.dart b/client_app/lib/features/chat/screens/thank_you_screen.dart new file mode 100644 index 0000000..a7ee3ec --- /dev/null +++ b/client_app/lib/features/chat/screens/thank_you_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; + +/// S11 — landing screen after a session has been closed. Replaces the +/// previous "navigate straight to home" behavior so the user gets a soft +/// acknowledgement before re-entering the home shell. +class ThankYouScreen extends StatelessWidget { + const ThankYouScreen({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) context.go('/home'); + }, + child: Scaffold( + backgroundColor: HaloTokens.bg, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '🤍', + style: TextStyle(fontSize: 72), + textAlign: TextAlign.center, + ), + const SizedBox(height: HaloSpacing.s24), + const Text( + 'makasih udah curhat', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: HaloSpacing.s12), + const Text( + 'semoga kamu lebih plong sekarang. kalau butuh, bestie selalu siap nemenin lagi.', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + color: HaloTokens.inkSoft, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: HaloSpacing.s40), + HaloButton( + label: 'balik ke home', + fullWidth: true, + onPressed: () => context.go('/home'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/widgets/closing_message_sheet.dart b/client_app/lib/features/chat/widgets/closing_message_sheet.dart new file mode 100644 index 0000000..2787aa6 --- /dev/null +++ b/client_app/lib/features/chat/widgets/closing_message_sheet.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/chat/session_closure_notifier.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_bottom_sheet.dart'; +import '../../../core/theme/widgets/halo_button.dart'; + +/// Stage 7 — replaces the legacy goodbye-composer screen with a bottom sheet. +/// The sheet is launched after the two-step confirm; it submits the goodbye +/// message AND closes the session. Both CTAs end the session — the difference +/// is whether a closing message is sent first. +class ClosingMessageSheet { + const ClosingMessageSheet._(); + + static Future show( + BuildContext context, { + required String sessionId, + required VoidCallback onCompleted, + }) { + return HaloBottomSheet.show( + context, + isScrollControlled: true, + child: _ClosingMessageBody( + sessionId: sessionId, + onCompleted: onCompleted, + ), + ); + } +} + +class _ClosingMessageBody extends ConsumerStatefulWidget { + final String sessionId; + final VoidCallback onCompleted; + + const _ClosingMessageBody({ + required this.sessionId, + required this.onCompleted, + }); + + @override + ConsumerState<_ClosingMessageBody> createState() => + _ClosingMessageBodyState(); +} + +class _ClosingMessageBodyState extends ConsumerState<_ClosingMessageBody> { + final _controller = TextEditingController(); + bool _busy = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _sendAndEnd() async { + final text = _controller.text.trim(); + if (text.isEmpty || _busy) return; + setState(() => _busy = true); + final notifier = ref.read(sessionClosureProvider.notifier); + await notifier.submitGoodbye(widget.sessionId, text); + await notifier.closeSession(widget.sessionId); + if (!mounted) return; + Navigator.of(context).pop(); + widget.onCompleted(); + } + + Future _skipAndEnd() async { + if (_busy) return; + setState(() => _busy = true); + await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId); + if (!mounted) return; + Navigator.of(context).pop(); + widget.onCompleted(); + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.only(bottom: viewInsets), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'pesan penutup', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 20, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'tulis sesuatu buat bestie sebelum sesi ditutup', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.inkSoft, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: HaloSpacing.s16), + TextField( + controller: _controller, + maxLines: 4, + minLines: 3, + enabled: !_busy, + decoration: const InputDecoration( + hintText: 'makasih ya bestie...', + filled: true, + fillColor: HaloTokens.brandSofter, + border: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: HaloSpacing.s16), + HaloButton( + label: 'kirim & akhiri sesi', + fullWidth: true, + onPressed: _busy ? null : _sendAndEnd, + ), + const SizedBox(height: HaloSpacing.s8), + HaloButton( + label: 'lewat — langsung akhiri', + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: _busy ? null : _skipAndEnd, + ), + ], + ), + ); + } +} diff --git a/client_app/lib/features/chat/widgets/confirm_end_step1.dart b/client_app/lib/features/chat/widgets/confirm_end_step1.dart new file mode 100644 index 0000000..63c9653 --- /dev/null +++ b/client_app/lib/features/chat/widgets/confirm_end_step1.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/widgets/halo_popup.dart'; + +/// Stage 7 — first of two confirm popups before ending the session. Surface +/// the soft "balik" exit prominently because the most common path here is the +/// user mis-tapping "akhiri sesi" while still wanting to continue. +class ConfirmEndStep1 { + const ConfirmEndStep1._(); + + static Future show( + BuildContext context, { + required VoidCallback onConfirm, + }) { + return HaloPopup.show( + context, + title: 'yakin mau akhiri sesi?', + body: 'sesi akan ditutup dan kamu balik ke home', + icon: const Text('🤔', style: TextStyle(fontSize: 40)), + primary: HaloPopupAction(label: 'lanjut akhiri', onPressed: onConfirm), + secondary: HaloPopupAction( + label: 'gak jadi, balik', + onPressed: () {}, + ), + ); + } +} diff --git a/client_app/lib/features/chat/widgets/confirm_end_step2.dart b/client_app/lib/features/chat/widgets/confirm_end_step2.dart new file mode 100644 index 0000000..69bacaa --- /dev/null +++ b/client_app/lib/features/chat/widgets/confirm_end_step2.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/widgets/halo_popup.dart'; + +/// Stage 7 — second confirm popup. Customer has already chosen to end; this +/// step nudges (not forces) them to leave a closing message, with `lewati saja` +/// as the bypass into the close-session API directly. +class ConfirmEndStep2 { + const ConfirmEndStep2._(); + + static Future show( + BuildContext context, { + required VoidCallback onWriteMessage, + required VoidCallback onSkip, + }) { + return HaloPopup.show( + context, + title: 'mau tinggalin pesan penutup?', + body: 'kamu bisa tulis pesan terakhir buat bestie sebelum sesi ditutup', + icon: const Text('💌', style: TextStyle(fontSize: 40)), + primary: HaloPopupAction( + label: 'tulis pesan penutup', + onPressed: onWriteMessage, + ), + secondary: HaloPopupAction(label: 'lewati saja', onPressed: onSkip), + ); + } +} diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 60d7efc..b5dabef 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -22,6 +22,7 @@ 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/chat/screens/thank_you_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'; @@ -230,6 +231,7 @@ GoRouter buildRouter(Ref ref) { mitraName: mitraName ?? 'Bestie', ); }), + GoRoute(path: '/chat/thank-you', builder: (_, __) => const ThankYouScreen()), GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()), GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!);