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/mitra_chat_notifier.dart'; import '../../../core/chat/extension_notifier.dart'; import '../../../core/chat/sensitivity_config_provider.dart'; import '../../../core/chat/widgets/sensitivity_badge.dart'; import '../../../core/chat/widgets/sensitivity_theme.dart'; import '../../../core/constants.dart'; // Chat theme colors const _kUserBubbleColor = Color(0xFFD4929A); const _kBannerColor = Color(0xFFC4868F); const _kAccentPink = Color(0xFFBE7C8A); class MitraChatScreen extends ConsumerStatefulWidget { final String sessionId; final String customerName; const MitraChatScreen({super.key, required this.sessionId, required this.customerName}); @override ConsumerState createState() => _MitraChatScreenState(); } class _MitraChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _goodbyeController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; bool _showBestieBanner = true; bool _showUserBanner = true; @override void initState() { super.initState(); Future.microtask(() { ref.read(mitraChatProvider.notifier).connect(widget.sessionId); }); } @override void dispose() { final notifier = ref.read(mitraChatProvider.notifier); _messageController.dispose(); _goodbyeController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); super.dispose(); Future.microtask(() => notifier.disconnect()); } 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(mitraChatProvider.notifier).sendTyping(); _typingThrottle = Timer(const Duration(seconds: 2), () {}); } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; ref.read(mitraChatProvider.notifier).sendMessage(text); _messageController.clear(); _scrollToBottom(); } @override Widget build(BuildContext context) { final chatState = ref.watch(mitraChatProvider); final extState = ref.watch(mitraExtensionProvider); // Listen for extension complete -> navigate home ref.listen(mitraExtensionProvider, (prev, next) { if (next is ExtensionCompleteData) { context.go('/home'); } }); // Listen for chat state changes ref.listen(mitraChatProvider, (prev, next) { if (next is MitraChatConnectedData) { _scrollToBottom(); final unread = next.messages .where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read) .map((m) => m.id) .toList(); if (unread.isNotEmpty) { ref.read(mitraChatProvider.notifier).markRead(unread); } } }); final currentSensitivity = chatState is MitraChatConnectedData ? chatState.topicSensitivity : TopicSensitivity.regular; return 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: () => context.pop(), ), title: Text(widget.customerName), actions: [ if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState), if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null) Padding( padding: const EdgeInsets.only(right: 16), child: Center( child: Text( '${chatState.remainingSeconds}s', style: TextStyle( color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black, fontWeight: FontWeight.bold, ), ), ), ), ], ), body: Column( children: [ if (currentSensitivity == TopicSensitivity.sensitive) _buildSensitivityHeader(), Expanded(child: _buildBody(chatState, extState)), ], ), ); } Widget _buildSensitivityHeader() { const theme = SensitivityTheme.sensitive; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), color: theme.badgeBg, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg), const SizedBox(width: 6), Text( 'Topik sensitif', style: TextStyle( color: theme.badgeFg, fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ), ); } Widget _buildTopicToggle(MitraChatConnectedData state) { final configAsync = ref.watch(sensitivityConfigProvider); final config = configAsync.value ?? SensitivityConfig.defaults; final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive; final locked = config.oneWayLatch && isSensitive; return Tooltip( message: locked ? 'Sesi sudah terkunci sebagai topik sensitif' : isSensitive ? 'Tandai sebagai topik umum' : 'Tandai sebagai topik sensitif', child: IconButton( icon: Icon( isSensitive ? Icons.flag : Icons.outlined_flag, color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600, ), onPressed: locked ? null : () => _onTopicTogglePressed(state, config), ), ); } Future _onTopicTogglePressed( MitraChatConnectedData state, SensitivityConfig config, ) async { final toValue = state.topicSensitivity == TopicSensitivity.sensitive ? TopicSensitivity.regular : TopicSensitivity.sensitive; if (config.flipConfirmationEnabled) { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text( toValue == TopicSensitivity.sensitive ? 'Tandai sesi ini sebagai sensitif?' : 'Tandai sesi ini sebagai topik umum?', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Tandai'), ), ], ), ); if (confirmed != true || !mounted) return; } final err = await ref .read(mitraChatProvider.notifier) .flipTopic(widget.sessionId, toValue); if (!mounted) return; if (err != null) { final msg = err == 'SENSITIVITY_LATCHED' ? 'Sesi sudah ditandai sensitif dan tidak bisa dikembalikan.' : err == 'SESSION_NOT_ACTIVE' ? 'Sesi sudah berakhir.' : 'Gagal mengubah topik. Coba lagi.'; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( toValue == TopicSensitivity.sensitive ? 'Sesi ditandai sensitif' : 'Sesi ditandai topik umum', ), duration: const Duration(seconds: 2), ), ); } } Widget _buildBody(MitraChatData chatState, ExtensionData extState) { if (chatState is MitraChatConnectingData) { return const Center(child: CircularProgressIndicator()); } if (chatState is MitraChatErrorData) { return Center(child: Text(chatState.message)); } if (chatState is MitraChatConnectedData) { return _buildChatBody(chatState, extState); } return const SizedBox.shrink(); } Widget _buildChatBody(MitraChatConnectedData state, ExtensionData extState) { // Extension request from customer if (state.extensionRequest != null) { return _buildExtensionView(state.extensionRequest!, extState); } // Goodbye view — suppress if mitra has already submitted their goodbye; // session can stay in `closing` while waiting for the customer to submit // theirs or for the 5-min grace timer to auto-complete. final showGoodbye = !state.goodbyeSubmitted && (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData); if (showGoodbye) { return _buildGoodbyeView(extState); } if (state.sessionClosing && state.goodbyeSubmitted) { return _buildAwaitingCustomerGoodbyeView(state); } final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint; return Stack( children: [ // Background pattern Positioned.fill( child: Container( color: bgTint, 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 — when the customer picked ESP topics during // onboarding, render a read-only chip row as the first list // item (above the first message bubble). Info-only. Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: state.messages.length + (state.topics.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { if (state.topics.isNotEmpty && index == 0) { return _buildTopicChipsRow(state.topics); } final msgIndex = state.topics.isNotEmpty ? index - 1 : index; final msg = state.messages[msgIndex]; final isMe = msg.senderType == UserType.mitra; 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('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), ), ), // Input bar _buildInputBar(), ], ), ], ); } // Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic` // enum's `label` property — we only need to read these here, not write. static const Map _espTopicLabels = { 'relationship': 'Hubungan', 'family': 'Keluarga', 'work': 'Pekerjaan', 'study': 'Sekolah / Kuliah', 'finance': 'Keuangan', 'health': 'Kesehatan', 'friendship': 'Pertemanan', 'self_worth': 'Self-worth', 'anxiety': 'Kecemasan', 'loneliness': 'Kesepian', 'grief': 'Kehilangan', 'identity': 'Identitas', }; Widget _buildTopicChipsRow(List topics) { return Container( margin: const EdgeInsets.only(bottom: 12), child: Wrap( spacing: 6, runSpacing: 6, children: topics.map((value) { final label = _espTopicLabels[value] ?? value; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(999), border: Border.all(color: const Color(0xFFE0CDD1)), ), child: Text( label, style: const TextStyle( fontSize: 12, color: _kAccentPink, fontWeight: FontWeight.w500, ), ), ); }).toList(), ), ); } 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(MitraChatMessage 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, ), ), ], ), ), ); } Widget _buildExtensionView(Map request, ExtensionData extState) { final duration = request['duration_minutes'] as int?; final extensionId = request['extension_id'] as String?; final isResponding = extState is ExtensionRespondingData; final topic = TopicSensitivity.fromString(request['topic_sensitivity'] as String?); final isSensitive = topic == TopicSensitivity.sensitive; // Extensions auto-approve on mitra non-response (server-side, with connectivity // safeguards). Surface the configured timeout to the mitra so they know what // "no response" means in this card. final timeoutSeconds = request['timeout_seconds'] as int?; return Container( color: isSensitive ? SensitivityTheme.sensitive.bgTint : null, child: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.timer, size: 64, color: Colors.orange), const SizedBox(height: 16), const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), if (isSensitive) ...[ const SizedBox(height: 8), SensitivityBadge(sensitivity: topic), ], const SizedBox(height: 8), Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), if (timeoutSeconds != null) ...[ const SizedBox(height: 12), Text( 'Tidak menjawab dalam $timeoutSeconds detik = otomatis disetujui', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, color: Colors.grey.shade700, fontStyle: FontStyle.italic, ), ), ], const SizedBox(height: 24), if (isResponding) const CircularProgressIndicator() else Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.green), onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( widget.sessionId, extensionId: extensionId, accepted: true, ), child: const Text('Terima', style: TextStyle(color: Colors.white)), ), const SizedBox(width: 16), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.red), onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( widget.sessionId, extensionId: extensionId, accepted: false, ), child: const Text('Tolak', style: TextStyle(color: Colors.white)), ), ], ), ], ), ), ), ); } Widget _buildGoodbyeView(ExtensionData extState) { return SingleChildScrollView( padding: const EdgeInsets.all(32), child: Column( children: [ const SizedBox(height: 48), 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 Customer', textAlign: TextAlign.center), const SizedBox(height: 24), TextField( controller: _goodbyeController, maxLines: 3, decoration: InputDecoration( hintText: 'Terima kasih sudah curhat...', border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), ), ), const SizedBox(height: 16), ElevatedButton( onPressed: extState is ExtensionSubmittingData ? null : () { final text = _goodbyeController.text.trim(); if (text.isNotEmpty) { ref.read(mitraExtensionProvider.notifier).submitGoodbye( widget.sessionId, text, ); } }, child: extState is ExtensionSubmittingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Kirim & Selesai'), ), ], ), ); } Widget _buildAwaitingCustomerGoodbyeView(MitraChatConnectedData state) { final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint; return Stack( children: [ Positioned.fill( child: Container( color: bgTint, child: Image.asset( 'assets/images/chat_pattern.png', repeat: ImageRepeat.repeat, fit: BoxFit.none, ), ), ), Column( children: [ Container( width: double.infinity, color: Colors.amber.shade100, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20), const SizedBox(width: 8), Expanded( child: Text( 'Pesan penutupmu sudah terkirim. Menunggu user...', style: TextStyle(color: Colors.amber.shade900, 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.mitra; return _buildMessageBubble(msg, isMe); }, ), ), ], ), ], ); } }