import 'dart:async'; 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/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_snackbar.dart'; import '../widgets/bestie_unavailable_dialog.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 const _kUserBubbleColor = Color(0xFFD4929A); const _kBgTint = Color(0xFFF5D0D6); const _kBannerColor = Color(0xFFC4868F); const _kAccentPink = Color(0xFFBE7C8A); const _kEndedBannerColor = Color(0xFFFFE0B2); // soft amber for early-end notice const _kEndedBannerText = Color(0xFF8B5A00); class ChatScreen extends ConsumerStatefulWidget { final String sessionId; final String mitraName; const ChatScreen({super.key, required this.sessionId, required this.mitraName}); @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _goodbyeController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; StreamSubscription? _warningSub; bool _showBestieBanner = true; bool _showUserBanner = true; 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- // and-braces here. bool _threeMinShown = false; @override void initState() { super.initState(); // The chat WebSocket is owned globally by `App` (see main.dart). // We just ask it to be on this session — no-op if it already is. Future.microtask(() { // Reset any closure state left over from a prior session view (the // closure notifier is keepAlive, so e.g. `ClosureCompleteData` from // the last goodbye submission would otherwise leak into this mount // and suppress the goodbye composer for a fresh `closing` session). ref.read(sessionClosureProvider.notifier).reset(); ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId); }); // Subscribe to the chat notifier's session-warning stream. Using stream // subscription rather than a `ref.listen` on state because the warning is // a one-shot signal, not a persistent state field. _warningSub = ref.read(chatProvider.notifier).warningStream.listen((kind) { if (kind == 'three_minutes_left' && !_threeMinShown && mounted) { _threeMinShown = true; HaloSnackbar.show( context, 'sisa 3 menit lagi ya 🤍', icon: '⏳', ); } }); } @override void dispose() { _messageController.dispose(); _goodbyeController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); _warningSub?.cancel(); super.dispose(); // Intentionally do NOT disconnect the WS here. The global lifecycle in // `App` decides when to disconnect (logout / no active session). } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } void _onTextChanged(String text) { if (_typingThrottle?.isActive ?? false) return; ref.read(chatProvider.notifier).sendTyping(); _typingThrottle = Timer(const Duration(seconds: 2), () {}); } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; ref.read(chatProvider.notifier).sendMessage(text); _messageController.clear(); _scrollToBottom(); } void _exitChat() { if (context.canPop()) { context.pop(); } else { context.go('/home'); } } /// 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; 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; await BestieOfflinePopup.show( context, variant: BestieOfflineVariant.returning, mitraName: widget.mitraName, ); _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); // 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) { ref.invalidate(activeSessionProvider); _goToThankYou(); } else if (next is ClosureRejectedByMitraData) { _showBestieReturningPopup(); } }); // 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. if (next.sessionClosing && !next.sessionExpired) { final closure = ref.read(sessionClosureProvider); if (closure is ClosureInitialData) { ref.read(sessionClosureProvider.notifier).declineExtension(); } } if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) { final closure = ref.read(sessionClosureProvider); if (closure is! ClosureInitialData) { ref.read(sessionClosureProvider.notifier).reset(); } } _scrollToBottom(); final unread = next.messages .where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read) .map((m) => m.id) .toList(); if (unread.isNotEmpty) { ref.read(chatProvider.notifier).markRead(unread); // Optimistically clear the home badge. ref.read(activeSessionProvider.notifier).markRead(); } } }); // Phase 4 — derived ticker drives the danger pill / expired banner. // Only watched when there's a connected session with a known expires_at. final remainingAsync = ref.watch(chatRemainingSecondsProvider); final remainingTick = remainingAsync.value; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _exitChat(); }, child: Scaffold( appBar: AppBar( backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0.5, centerTitle: true, leading: IconButton( icon: const Icon(Icons.chevron_left, size: 28), onPressed: _exitChat, ), title: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: Text( widget.mitraName, overflow: TextOverflow.ellipsis, ), ), if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[ const SizedBox(width: 8), _buildVoiceCallPill(), ], ], ), actions: [ if (chatState is ChatConnectedData && remainingTick != null) Padding( 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), ), ); } Widget _buildVoiceCallPill() { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: const BoxDecoration( color: HaloTokens.accent, borderRadius: HaloRadius.pill, ), child: const Text( '📞 Voice Call', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, ), ), ); } Widget _buildTimerPill(int remaining) { final danger = remaining <= 120; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: danger ? HaloTokens.danger : Colors.transparent, borderRadius: HaloRadius.pill, ), child: Text( formatCountdown(remaining), style: TextStyle( fontFamily: HaloTokens.fontMono, fontSize: 13, fontWeight: danger ? FontWeight.w700 : FontWeight.w600, color: danger ? Colors.white : HaloTokens.ink, ), ), ); } Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) { if (chatState is ChatConnectingData) { return const Center(child: CircularProgressIndicator()); } if (chatState is ChatErrorData) { return Center(child: Text(chatState.message)); } if (chatState is ChatConnectedData) { return _buildChatBody(chatState, closureState, remainingTick); } return const SizedBox.shrink(); } Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) { // Show goodbye composer when closure flow is in goodbye/submitting OR when // we mounted directly into a `closing` session (e.g. opened from history). // The chatProvider listener can't catch this case because it only fires on // transitions, not the current state at mount time. // Suppress when the customer has already submitted their goodbye — the // session can stay in `closing` while waiting for the mitra to submit // their own message or for the 5-min grace timer to auto-complete. final shouldShowGoodbye = !state.goodbyeSubmitted && (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData || (state.sessionClosing && !state.sessionExpired && closureState is! ClosureCompleteData)); if (shouldShowGoodbye) { return _buildGoodbyeView(closureState); } if (state.sessionClosing && state.goodbyeSubmitted) { return _buildAwaitingMitraGoodbyeView(state); } if (state.sessionPaused) { return _buildPausedView(); } return Stack( children: [ // Background pattern Positioned.fill( child: Container( color: _kBgTint, child: Image.asset( 'assets/images/chat_pattern.png', repeat: ImageRepeat.repeat, fit: BoxFit.none, ), ), ), // Content Column( children: [ // Entry banners if (_showBestieBanner) _buildEntryBanner( '[Bestie] Sudah Memasuki Ruangan', () => setState(() => _showBestieBanner = false), ), if (_showUserBanner) _buildEntryBanner( '[User] Sudah Memasuki Ruangan', () => setState(() => _showUserBanner = false), ), // Messages Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: state.messages.length, itemBuilder: (context, index) { final msg = state.messages[index]; final isMe = msg.senderType == UserType.customer; return _buildMessageBubble(msg, isMe); }, ), ), // Typing indicator if (state.isOtherTyping) const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Align( alignment: Alignment.centerLeft, child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), ), ), // Floating expired banner — visible while the timer has hit zero // and the session hasn't been finalized yet (still in closing // grace). Tapping `perpanjang` opens the time-up sheet, same as // the modal route. if (remainingTick != null && remainingTick <= 0) ChatExpiredBanner( onExtend: () => PricingBottomSheet.showForExtension( context, sessionId: widget.sessionId, ), ), // Input bar — disabled when timer expired (modal handles next step) if (!state.sessionExpired) _buildInputBar(), ], ), ], ); } Widget _buildEntryBanner(String text, VoidCallback onDismiss) { return Container( color: _kBannerColor, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ const Icon(Icons.volume_up, color: Colors.white, size: 18), const SizedBox(width: 8), Expanded( child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)), ), GestureDetector( onTap: onDismiss, child: const Icon(Icons.close, color: Colors.white, size: 18), ), ], ), ); } Widget _buildMessageBubble(ChatMessage msg, bool isMe) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), decoration: BoxDecoration( color: isMe ? _kUserBubbleColor : Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(msg.content, style: const TextStyle(fontSize: 15)), const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey), ), if (isMe) ...[ const SizedBox(width: 4), _buildStatusIcon(msg.status), ], ], ), ], ), ), ); } Widget _buildStatusIcon(String status) { switch (status) { case 'sending': return const Icon(Icons.access_time, size: 14, color: Colors.white70); case MessageStatus.sent: return const Icon(Icons.check, size: 14, color: Colors.white70); case MessageStatus.delivered: return const Icon(Icons.done_all, size: 14, color: Colors.white70); case MessageStatus.read: return const Icon(Icons.done_all, size: 14, color: Colors.white); default: return const SizedBox.shrink(); } } Widget _buildInputBar() { return SafeArea( child: Container( padding: const EdgeInsets.all(8), color: Colors.white, child: Row( children: [ Expanded( child: TextField( controller: _messageController, onChanged: _onTextChanged, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( hintText: 'Ketik Pesan', hintStyle: TextStyle(color: Colors.grey.shade400), filled: true, fillColor: Colors.grey.shade100, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), ), ), const SizedBox(width: 8), Container( decoration: const BoxDecoration( color: _kAccentPink, shape: BoxShape.circle, ), child: IconButton( icon: const Icon(Icons.send, color: Colors.white, size: 20), onPressed: _sendMessage, ), ), ], ), ), ); } // 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), child: Column( children: [ // Early-end banner — visually distinct, separate from the closing // composer below. We don't surface "Perpanjang" in this flow. Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: _kEndedBannerColor, borderRadius: BorderRadius.circular(8), ), child: const Row( children: [ Icon(Icons.info_outline, color: _kEndedBannerText, size: 20), SizedBox(width: 8), Expanded( child: Text( 'Sesi telah ditutup oleh Bestie', style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600), ), ), ], ), ), const SizedBox(height: 32), const Icon(Icons.waving_hand, size: 64, color: Colors.amber), const SizedBox(height: 16), const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 8), const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center), const SizedBox(height: 24), TextField( controller: _goodbyeController, maxLines: 3, decoration: InputDecoration( hintText: 'Terima kasih, Bestie...', border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), ), ), const SizedBox(height: 16), ElevatedButton( onPressed: closureState is ClosureSubmittingData ? null : () { final text = _goodbyeController.text.trim(); if (text.isNotEmpty) { ref.read(sessionClosureProvider.notifier).submitGoodbye( widget.sessionId, text, ); } }, child: closureState is ClosureSubmittingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Kirim & Selesai'), ), ], ), ); } Widget _buildPausedView() { return const Center( child: Padding( padding: EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 24), Text('Menunggu konfirmasi Bestie...', style: TextStyle(fontSize: 18)), SizedBox(height: 8), Text('Chat dijeda sementara', style: TextStyle(color: Colors.grey)), ], ), ), ); } Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) { return Stack( children: [ Positioned.fill( child: Container( color: _kBgTint, child: Image.asset( 'assets/images/chat_pattern.png', repeat: ImageRepeat.repeat, fit: BoxFit.none, ), ), ), Column( children: [ Container( width: double.infinity, color: _kEndedBannerColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: const Row( children: [ Icon(Icons.hourglass_top, color: _kEndedBannerText, size: 20), SizedBox(width: 8), Expanded( child: Text( 'Pesan penutupmu sudah terkirim. Menunggu Bestie...', style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600), ), ), ], ), ), Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: state.messages.length, itemBuilder: (context, index) { final msg = state.messages[index]; final isMe = msg.senderType == UserType.customer; return _buildMessageBubble(msg, isMe); }, ), ), ], ), ], ); } }