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'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/halo_orb.dart'; // Mitra bubble gradient — matches `BestieChatV5` (figma-bestie/.../v5.jsx:282) // pink → purple direction (135deg ≈ topLeft → bottomRight). const _kMitraBubbleGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFC44979), Color(0xFF8B5CF6)], ); 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; @override void initState() { super.initState(); Future.microtask(() { ref.read(mitraChatProvider.notifier).connect(widget.sessionId); }); } @override void deactivate() { // Disconnect runs here, NOT in dispose(): modern Riverpod invalidates // `ref` the instant dispose() starts, and the resulting silent error // corrupts the widget-tree finalize (next screen freezes). deactivate() // runs BEFORE dispose() while `ref` is still valid. Same fix pattern // applied in client_app/home_screen + payment_screen on 2026-05-14. // ignore: discarded_futures ref.read(mitraChatProvider.notifier).disconnect(); super.deactivate(); } @override void dispose() { _messageController.dispose(); _goodbyeController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); super.dispose(); } 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) { // Parent build runs ONCE per lifecycle — there are no ref.watch calls here. // State changes (messages, typing, status updates, mode flip, sensitivity // flip) all rebuild only the leaf consumers that watch them: // - _MitraChatSubtitle → mode + sessionExpired (via .select) // - _MitraChatTopicToggle → topicSensitivity (via .select) + config // - _MitraChatTimerAction → mitraChatRemainingSecondsProvider // - _MitraChatBodyContent → full chatProvider + extensionProvider // Pattern mirrors client_app/chat_screen post-refactor (2026-05-14). ref.listen(mitraExtensionProvider, (prev, next) { if (next is ExtensionCompleteData) { context.go('/home'); } }); 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); } } }); // Orb seed derived from the sessionId — stable per session, identical // to the convention used by `undangan_screen.dart` for incoming requests. // We don't have a separate customerId in this screen scope (only name + // sessionId), and the spec explicitly allows hashing sessionId here. final orbSeed = widget.sessionId.hashCode; return Scaffold( backgroundColor: HaloTokens.bg, appBar: AppBar( backgroundColor: HaloTokens.surface, foregroundColor: HaloTokens.ink, elevation: 0.5, centerTitle: false, titleSpacing: 0, leading: IconButton( icon: const Icon(Icons.chevron_left, size: 28, color: HaloTokens.ink), onPressed: () => context.pop(), ), title: Row( mainAxisSize: MainAxisSize.min, children: [ HaloOrb(size: 38, seed: orbSeed), const SizedBox(width: 10), Flexible( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _MitraChatTitleName(fallback: widget.customerName), const _MitraChatSubtitle(), ], ), ), ], ), actions: [ _MitraChatTopicToggle(sessionId: widget.sessionId), const _MitraChatTimerAction(), ], ), body: _MitraChatBodyContent( sessionId: widget.sessionId, customerName: widget.customerName, messageController: _messageController, goodbyeController: _goodbyeController, scrollController: _scrollController, onSend: _sendMessage, onTextChanged: _onTextChanged, ), ); } } /// AppBar subtitle line. Watches only the `mode` flag and `sessionExpired` /// flag (each via `.select`), so a WS message / typing / status update never /// rebuilds this widget — only an actual mode flip or expiry transition does. /// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:239): "sesi aktif · Chat", /// "sesi aktif · Voice", or "sesi berakhir" (color #A8410E when ended). /// AppBar customer-name line. Reads customerDisplayName from the chat /// connected-state via `.select` so the parent AppBar doesn't rebuild on /// every chat-state change. Falls back to the route-arg `fallback` value /// (which is "Customer" when reached via FCM tap — see notification_service /// `_navigateFromMessage`). class _MitraChatTitleName extends ConsumerWidget { final String fallback; const _MitraChatTitleName({required this.fallback}); @override Widget build(BuildContext context, WidgetRef ref) { final name = ref.watch(mitraChatProvider.select((s) { if (s is MitraChatConnectedData) return s.customerDisplayName; return null; })); final displayed = (name != null && name.isNotEmpty) ? name : fallback; return Text( displayed, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: HaloTokens.ink, height: 1.2, ), ); } } class _MitraChatSubtitle extends ConsumerWidget { const _MitraChatSubtitle(); static const _endedInk = Color(0xFFA8410E); @override Widget build(BuildContext context, WidgetRef ref) { final isCall = ref.watch(mitraChatProvider.select( (s) => s is MitraChatConnectedData && s.mode == SessionMode.call, )); final ended = ref.watch(mitraChatProvider.select( (s) => s is MitraChatConnectedData && s.sessionExpired, )); final text = ended ? 'sesi berakhir' : isCall ? 'sesi aktif · Voice' : 'sesi aktif · Chat'; return Text( text, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w400, color: ended ? _endedInk : HaloTokens.inkSoft, height: 1.2, ), ); } } /// AppBar topic-sensitivity flag/lock action. Watches only `topicSensitivity` /// (via `.select`) plus the `sensitivityConfigProvider`. Confirmation dialog /// + snackbars + `flipTopic` call all live here so the parent screen doesn't /// need to know about topic state. class _MitraChatTopicToggle extends ConsumerStatefulWidget { final String sessionId; const _MitraChatTopicToggle({required this.sessionId}); @override ConsumerState<_MitraChatTopicToggle> createState() => _MitraChatTopicToggleState(); } class _MitraChatTopicToggleState extends ConsumerState<_MitraChatTopicToggle> { @override Widget build(BuildContext context) { final sensitivity = ref.watch(mitraChatProvider.select((s) { if (s is MitraChatConnectedData) return s.topicSensitivity; return null; })); if (sensitivity == null) return const SizedBox.shrink(); final configAsync = ref.watch(sensitivityConfigProvider); final config = configAsync.value ?? SensitivityConfig.defaults; final isSensitive = sensitivity == 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(sensitivity, config), ), ); } Future _onTopicTogglePressed( TopicSensitivity current, SensitivityConfig config, ) async { final toValue = current == 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), ), ); } } } /// Owns the chat-body subtree. Watches `mitraChatProvider` and /// `mitraExtensionProvider` — so a WS message / typing / status / extension /// update rebuilds *this* widget only, not the parent Scaffold or AppBar. /// Entry-banner dismiss state moved here from the parent so its setState /// doesn't propagate back up either. class _MitraChatBodyContent extends ConsumerStatefulWidget { final String sessionId; final String customerName; final TextEditingController messageController; final TextEditingController goodbyeController; final ScrollController scrollController; final VoidCallback onSend; final ValueChanged onTextChanged; const _MitraChatBodyContent({ required this.sessionId, required this.customerName, required this.messageController, required this.goodbyeController, required this.scrollController, required this.onSend, required this.onTextChanged, }); @override ConsumerState<_MitraChatBodyContent> createState() => _MitraChatBodyContentState(); } class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> { // 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', }; @override Widget build(BuildContext context) { final chatState = ref.watch(mitraChatProvider); final extState = ref.watch(mitraExtensionProvider); final isSensitive = chatState is MitraChatConnectedData && chatState.topicSensitivity == TopicSensitivity.sensitive; return Column( children: [ if (isSensitive) _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 _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, state.customerDisplayName); } if (state.sessionClosing && state.goodbyeSubmitted) { return _buildAwaitingCustomerGoodbyeView(state); } // Background: flat cream (HaloTokens.bg) per Figma `BestieChatV5`. Drop // the previous `chat_pattern.png` wallpaper layer. Sensitivity tint still // applies for the sensitive case — it's a working feature; for regular // sessions we use the flat brand bg so the cream/Figma look comes through. final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive; final bg = isSensitive ? SensitivityTheme.sensitive.bgTint : HaloTokens.bg; // Build list payload: leading "sesi dimulai" system pill, optional ESP // topic chip row, then the message bubbles. Single source of truth for // index → item resolution so the ListView builder stays simple. final hasTopics = state.topics.isNotEmpty; // [0]=system pill, [1]=topics (optional), then messages. final leadingCount = 1 + (hasTopics ? 1 : 0); return Container( color: bg, child: Column( children: [ // Session-ended banner (mirrors `BestieChatV5` ended-state notice in // figma-bestie/.../v5.jsx:294). Pinned directly under the header so // it's visible regardless of scroll position. if (state.sessionExpired) _buildSessionEndedBanner(), Expanded( child: ListView.builder( controller: widget.scrollController, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), itemCount: state.messages.length + leadingCount, itemBuilder: (context, index) { if (index == 0) return _buildSystemPill('sesi dimulai'); if (hasTopics && index == 1) { return _buildTopicChipsRow(state.topics); } final msg = state.messages[index - leadingCount]; final isMe = msg.senderType == UserType.mitra; return _buildMessageBubble(msg, isMe); }, ), ), // Typing indicator if (state.isOtherTyping) const Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 6), child: Align( alignment: Alignment.centerLeft, child: Text( 'Customer sedang mengetik...', style: TextStyle(color: HaloTokens.inkMuted, fontSize: 12), ), ), ), // Input bar — softer disabled-state notice once the timer hits // zero. Tunggu klien perpanjang / tutup obrolan; we don't accept // new mitra messages on an expired session. if (state.sessionExpired) _buildEndedInputNotice() else _buildInputBar(), ], ), ); } /// Centered system-message pill. Replaces the legacy bracketed entry /// banners. Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:255). Widget _buildSystemPill(String text) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Align( alignment: Alignment.center, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: const BoxDecoration( color: HaloTokens.brandSofter, borderRadius: HaloRadius.pill, ), child: Text( text, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: HaloTokens.brand, ), ), ), ), ); } 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: HaloTokens.surface, borderRadius: HaloRadius.pill, border: Border.all(color: HaloTokens.border), ), child: Text( label, style: const TextStyle( fontSize: 12, color: HaloTokens.brandDark, fontWeight: FontWeight.w500, ), ), ); }).toList(), ), ); } Widget _buildSessionEndedBanner() { const bg = Color(0xFFFFE5E5); const border = Color(0xFFF5B5B5); const ink = Color(0xFF7A2828); return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: const BoxDecoration( color: bg, border: Border(bottom: BorderSide(color: border)), ), child: const Row( children: [ Icon(Icons.access_time, color: ink, size: 18), SizedBox(width: 10), Expanded( child: Text.rich( TextSpan( children: [ TextSpan( text: 'Durasi sesi habis. ', style: TextStyle(fontWeight: FontWeight.w700, color: ink), ), TextSpan( text: 'Tunggu klien perpanjang atau tutup obrolan.', style: TextStyle(color: ink), ), ], ), style: TextStyle(fontSize: 12.5, height: 1.4), ), ), ], ), ); } Widget _buildEndedInputNotice() { return SafeArea( child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: HaloTokens.border)), ), child: const Text( 'Sesi sudah berakhir 💛', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, color: HaloTokens.inkSoft, fontWeight: FontWeight.w500, ), ), ), ); } // TODO(stage7): reply-quote bubble polish. Figma `BestieChatV5` renders a // quote block above the bubble text when a message replies to another // (2px left accent stripe, original sender name, truncated original text). // Out of scope for Stage 6 — the `MitraChatMessage` model in // `core/chat/mitra_chat_notifier.dart` does not carry a `replyTo` payload // and the backend WS frames don't ship one. Adding it requires changes to // backend message schema + this notifier + ChatService. Until then there // are no reply-to messages to render. Corner-radius logic below already // accepts the future `replyTo` case (top corner becomes 4 when replying). Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) { // Tail corner: 4 on the side facing the sender's edge (bottom-right for // me, bottom-left for them). In stage7 the top corner will also become // 4 when this message replies to another — see TODO(stage7) above. final radius = BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMe ? 16 : 4), bottomRight: Radius.circular(isMe ? 4 : 16), ); final maxW = MediaQuery.of(context).size.width * 0.78; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxW), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( gradient: isMe ? _kMitraBubbleGradient : null, color: isMe ? null : HaloTokens.surface, border: isMe ? null : Border.all(color: HaloTokens.border), borderRadius: radius, ), child: Text( msg.content, style: TextStyle( fontSize: 13.5, height: 1.45, color: isMe ? Colors.white : HaloTokens.ink, ), ), ), const SizedBox(height: 2), Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', style: const TextStyle(fontSize: 10, color: HaloTokens.inkMuted), ), if (isMe) ...[ const SizedBox(width: 4), _buildStatusIcon(msg.status), ], ], ), ], ), ), ), ); } Widget _buildStatusIcon(String status) { switch (status) { case 'sending': return const Icon(Icons.access_time, size: 12, color: HaloTokens.inkMuted); case MessageStatus.sent: return const Icon(Icons.check, size: 12, color: HaloTokens.inkMuted); case MessageStatus.delivered: return const Icon(Icons.done_all, size: 12, color: HaloTokens.inkMuted); case MessageStatus.read: return const Icon(Icons.done_all, size: 12, color: HaloTokens.brand); default: return const SizedBox.shrink(); } } Widget _buildInputBar() { // Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:305): cream-bg pill text // field with a round pink send button. SafeArea takes care of the home- // indicator inset; the JSX uses a hardcoded 30px bottom pad — we let // SafeArea handle it instead so devices without a home indicator don't // get a giant gap. return Container( decoration: const BoxDecoration( color: HaloTokens.surface, border: Border(top: BorderSide(color: HaloTokens.border)), ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), // Both children rendered inside a Container(height: 44) so their // visible heights match exactly. Earlier attempt used // OutlineInputBorder on the TextField directly, but OutlineInputBorder // sizes to text content (not the parent SizedBox), so the pill came // out at ~29dp while the round button was 44dp — visual centers // drifted ~43px apart. This pattern is bulletproof: a Container // draws the pill outline + bg, a borderless centered TextField sits // inside, and the send button is the same explicit 44dp Container. child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: const BoxDecoration( color: HaloTokens.bg, borderRadius: HaloRadius.pill, border: Border.fromBorderSide( BorderSide(color: HaloTokens.border), ), ), child: TextField( controller: widget.messageController, onChanged: widget.onTextChanged, textInputAction: TextInputAction.send, onSubmitted: (_) => widget.onSend(), textAlignVertical: TextAlignVertical.center, style: const TextStyle(fontSize: 13.5, color: HaloTokens.ink), decoration: const InputDecoration( hintText: 'ketik balasan...', hintStyle: TextStyle(color: HaloTokens.inkMuted, fontSize: 13.5), isCollapsed: true, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, ), ), ), ), const SizedBox(width: 8), Container( width: 44, height: 44, decoration: const BoxDecoration( color: HaloTokens.brand, shape: BoxShape.circle, ), child: Material( color: Colors.transparent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: widget.onSend, child: const Center( child: Icon(Icons.arrow_upward, color: Colors.white, size: 18), ), ), ), ), ], ), ), ), ); } 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, String? customerDisplayName) { final name = (customerDisplayName != null && customerDisplayName.isNotEmpty) ? customerDisplayName : widget.customerName; 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), Text('Tuliskan pesan terakhirmu untuk $name', textAlign: TextAlign.center), const SizedBox(height: 24), // Pill-shaped, single-line text field with the border color picked // up from the previous "shadow" tone (HaloTokens.border). Container // fixes the height so textAlignVertical can center the entry. Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 20), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: BorderRadius.circular(100), border: Border.all(color: HaloTokens.border, width: 1), ), child: TextField( controller: widget.goodbyeController, maxLines: 1, textAlignVertical: TextAlignVertical.center, style: const TextStyle(fontSize: 14, color: HaloTokens.ink), decoration: const InputDecoration( hintText: 'Terima kasih sudah curhat...', hintStyle: TextStyle(color: HaloTokens.inkMuted, fontSize: 14), isCollapsed: true, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, ), ), ), const SizedBox(height: 16), ElevatedButton( onPressed: extState is ExtensionSubmittingData ? null : () { final text = widget.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 isSensitive = state.topicSensitivity == TopicSensitivity.sensitive; final bg = isSensitive ? SensitivityTheme.sensitive.bgTint : HaloTokens.bg; return Container( color: bg, child: 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: widget.scrollController, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), itemCount: state.messages.length, itemBuilder: (context, index) { final msg = state.messages[index]; final isMe = msg.senderType == UserType.mitra; return _buildMessageBubble(msg, isMe); }, ), ), ], ), ); } } /// Tiny AppBar action that watches only [mitraChatRemainingSecondsProvider]. /// Decoupling the timer from the chat state means a WS `session_tick` frame /// rebuilds *only* this widget (a single Text), not the surrounding AppBar, /// Scaffold body, message ListView, or input bar. This is the per-second /// hotspot the wider chat-screen perf work targets. /// /// Visual mirrors `BestieChatV5` (figma-bestie/.../v5.jsx): /// - active → amber pill "SISA WAKTU\nmm:ss" (#FFE8D9 bg / #FFCAA0 border) /// - ended → red pill "SELESAI\n00:00" (#FFE5E5 bg / #F5B5B5 border) /// "Ended" here is `seconds <= 0`. The session-expired flag lives on /// `mitraChatProvider` but reading it here would re-couple this widget to /// the chat state and defeat the per-second perf isolation — so we use the /// timer reaching zero as a proxy. The red banner below the header uses the /// authoritative `sessionExpired` flag. class _MitraChatTimerAction extends ConsumerWidget { const _MitraChatTimerAction(); static const _activeBg = Color(0xFFFFE8D9); static const _activeBorder = Color(0xFFFFCAA0); static const _activeInk = Color(0xFF7A3E08); static const _endedBg = Color(0xFFFFE5E5); static const _endedBorder = Color(0xFFF5B5B5); static const _endedInk = Color(0xFF7A2828); @override Widget build(BuildContext context, WidgetRef ref) { final seconds = ref.watch(mitraChatRemainingSecondsProvider); // When the first session_timer frame hasn't arrived yet (`seconds == null`) // show a placeholder pill — matches the Figma which always renders the // pill — instead of disappearing. Treat null as "loading", not "ended". final loading = seconds == null; final ended = !loading && seconds <= 0; final mm = loading ? '--' : (seconds ~/ 60).clamp(0, 99).toString().padLeft(2, '0'); final ss = loading ? '--' : (seconds % 60).clamp(0, 59).toString().padLeft(2, '0'); final label = ended ? 'SELESAI' : 'SISA WAKTU'; final value = ended ? '00:00' : '$mm:$ss'; final bg = ended ? _endedBg : _activeBg; final border = ended ? _endedBorder : _activeBorder; final ink = ended ? _endedInk : _activeInk; return Padding( padding: const EdgeInsets.only(right: 12), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: bg, borderRadius: HaloRadius.sm, border: Border.all(color: border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: TextStyle( fontSize: 8, fontWeight: FontWeight.w700, letterSpacing: 0.5, color: ink, ), ), Text( value, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: ink, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), ), ); } }