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/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'; /// S10 Chat Room โ€” strict Figma implementation (Phase 4, 2026-05-12). /// /// Source-of-truth: `requirement/Figma/screens/session.jsx::S10Chat` (lines /// 150โ€“284) + `v3.jsx::HBChatExpiredBanner` (line 423). Phase 4 deltas the /// older design had (entry banners, AppBar `akhiri` button, doodle bg) are /// dropped โ€” see [requirement/flow_customer.mermaid.md] ยง5. 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 _rejectPopupShown = false; bool _threeMinShown = false; @override void initState() { super.initState(); Future.microtask(() { ref.read(sessionClosureProvider.notifier).reset(); ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId); }); _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(); } void _scrollToBottom() { void doScroll() { if (!mounted || !_scrollController.hasClients) return; _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } // Two passes: first captures the new bubble after the rebuild's layout; // second catches up once the keyboard animation finishes growing // maxScrollExtent. ~320ms covers the Android soft-keyboard rise. WidgetsBinding.instance.addPostFrameCallback((_) { doScroll(); Future.delayed(const Duration(milliseconds: 320), doScroll); }); } void _onTextChanged(String _) { 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'); } } 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; ref.read(sessionClosureProvider.notifier).reset(); } @override Widget build(BuildContext context) { // All `ref.listen` calls โ€” pure side effects, never trigger rebuilds. The // parent ChatScreen used to `ref.watch(chatProvider)` + `ref.watch(timer)` // which forced a full-tree rebuild every second (timer ticks) and on every // WS frame; now those watches live in the leaf widgets that actually need // them (_ChatHeader for the timer, _ChatBodySection for the message list). ref.listen(sessionClosureProvider, (prev, next) { if (next is ClosureCompleteData) { ref.invalidate(activeSessionProvider); _goToThankYou(); } else if (next is ClosureRejectedByMitraData) { _showBestieReturningPopup(); } }); ref.listen(chatProvider, (prev, next) { if (next is ChatConnectedData) { 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); ref.read(activeSessionProvider.notifier).markRead(); } } }); // 3-min snackbar side effect on the timer stream. Listening (not watching) // means parent doesn't rebuild every second โ€” only this callback fires. // Backend also emits `session_warning kind=three_minutes_left` (handled in // initState via `warningStream`); `_threeMinShown` dedupes either path. ref.listen(chatRemainingSecondsProvider, (prev, next) { final tick = next.valueOrNull; if (tick == null) return; if (tick > 0 && tick <= 180 && !_threeMinShown && mounted) { _threeMinShown = true; HaloSnackbar.show(context, 'sisa 3 menit lagi ya ๐Ÿค', icon: 'โณ'); } // Re-arm when the session is extended back above 180s. if (tick > 180 && _threeMinShown) { _threeMinShown = false; } }); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _exitChat(); }, child: Scaffold( backgroundColor: HaloTokens.brandSofter, body: SafeArea( bottom: false, child: Column( children: [ _ChatHeader(mitraName: widget.mitraName, onBack: _exitChat), Expanded( child: _ChatBodySection( sessionId: widget.sessionId, mitraName: widget.mitraName, messageController: _messageController, goodbyeController: _goodbyeController, scrollController: _scrollController, onSend: _sendMessage, onTextChanged: _onTextChanged, ), ), ], ), ), ), ); } } // โ”€โ”€โ”€ Body section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // Watches `chatProvider` + `sessionClosureProvider` and rebuilds only on those. // The timer stream is NOT watched here โ€” the lowTime/expired banners that need // it live inside a tiny dedicated `Consumer` so timer ticks rebuild ONLY that // banner, not the message list or the input bar. class _ChatBodySection extends ConsumerWidget { final String sessionId; final String mitraName; final TextEditingController messageController; final TextEditingController goodbyeController; final ScrollController scrollController; final VoidCallback onSend; final ValueChanged onTextChanged; const _ChatBodySection({ required this.sessionId, required this.mitraName, required this.messageController, required this.goodbyeController, required this.scrollController, required this.onSend, required this.onTextChanged, }); @override Widget build(BuildContext context, WidgetRef ref) { final chatState = ref.watch(chatProvider); final closureState = ref.watch(sessionClosureProvider); 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(context, ref, chatState, closureState); } return const SizedBox.shrink(); } Widget _buildChatBody( BuildContext context, WidgetRef ref, ChatConnectedData state, SessionClosureData closureState, ) { final shouldShowGoodbye = !state.goodbyeSubmitted && (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData || (state.sessionClosing && !state.sessionExpired && closureState is! ClosureCompleteData)); if (shouldShowGoodbye) { return _buildGoodbyeView(ref, closureState); } if (state.sessionClosing && state.goodbyeSubmitted) { return _buildAwaitingMitraGoodbyeView(state); } if (state.sessionPaused) { return _buildPausedView(); } return Column( children: [ Expanded( child: ListView.builder( controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), itemCount: state.messages.length + (state.isOtherTyping ? 1 : 0), itemBuilder: (listCtx, index) { if (state.isOtherTyping && index == state.messages.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 4), child: Align( alignment: Alignment.centerLeft, child: _TypingPill(), ), ); } final msg = state.messages[index]; final isMe = msg.senderType == UserType.customer; return _MessageBubble(msg: msg, isMe: isMe); }, ), ), // Banner gating runs on the timer stream โ€” scoped to its own Consumer // so only the banner widget rebuilds every second, not the list or // input bar above/below. _TimerBanner(sessionId: sessionId, mitraName: mitraName), if (!state.sessionExpired) ...[ _InputBar( controller: messageController, onChanged: onTextChanged, onSend: onSend, ), const _EncryptedFooter(), ], ], ); } // Inline goodbye composer for the mitra-initiated early-end case // (sessionClosing true, customer hasn't been routed through the // ClosingMessageSheet). Primary path is the dedicated [ClosingMessageSheet]. Widget _buildGoodbyeView(WidgetRef ref, SessionClosureData closureState) { return SingleChildScrollView( padding: const EdgeInsets.all(32), child: Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: HaloTokens.accentSoft, borderRadius: BorderRadius.circular(12), ), child: const Row( children: [ Icon(Icons.info_outline, color: HaloTokens.brandDark, size: 20), SizedBox(width: 8), Expanded( child: Text( 'sesi telah ditutup oleh bestie', style: TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.brandDark, fontWeight: FontWeight.w600, ), ), ), ], ), ), const SizedBox(height: 32), const Text('๐Ÿค', style: TextStyle(fontSize: 48)), const SizedBox(height: 16), const Text( 'pesan penutup', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 22, fontWeight: FontWeight.w700, color: HaloTokens.ink, ), ), const SizedBox(height: 8), const Text( 'tuliskan pesan terakhirmu untuk bestie', textAlign: TextAlign.center, style: TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.inkSoft, ), ), const SizedBox(height: 24), TextField( controller: goodbyeController, maxLines: 3, decoration: InputDecoration( hintText: 'terima kasih, bestie...', filled: true, fillColor: HaloTokens.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: HaloTokens.border), ), ), ), const SizedBox(height: 16), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: HaloTokens.brand, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), ), onPressed: closureState is ClosureSubmittingData ? null : () { final text = goodbyeController.text.trim(); if (text.isNotEmpty) { ref.read(sessionClosureProvider.notifier).submitGoodbye( sessionId, text, ); } }, child: closureState is ClosureSubmittingData ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : 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( fontFamily: HaloTokens.fontBody, fontSize: 16, color: HaloTokens.ink, ), ), SizedBox(height: 8), Text( 'chat dijeda sementara', style: TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.inkSoft, ), ), ], ), ), ); } Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) { return Column( children: [ Container( width: double.infinity, color: HaloTokens.accentSoft, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: const Row( children: [ Icon(Icons.hourglass_top, color: HaloTokens.brandDark, size: 20), SizedBox(width: 8), Expanded( child: Text( 'pesan penutupmu sudah terkirim. menunggu bestie...', style: TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.brandDark, 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 _MessageBubble(msg: msg, isMe: isMe); }, ), ), ], ); } } // Tiny dedicated consumer for the in-chat low-time / expired banner. Scoped // here so timer ticks rebuild only this widget โ€” the message list above and // input bar below stay still. Uses `.select` to collapse the timer stream to // a 3-state enum so the rebuild only fires on banner-state transitions, not // every second. enum _BannerKind { none, lowTime, expired } class _TimerBanner extends ConsumerWidget { final String sessionId; final String mitraName; const _TimerBanner({required this.sessionId, required this.mitraName}); @override Widget build(BuildContext context, WidgetRef ref) { final kind = ref.watch(chatRemainingSecondsProvider.select((async) { final r = async.valueOrNull; if (r == null) return _BannerKind.none; if (r <= 0) return _BannerKind.expired; if (r < 120) return _BannerKind.lowTime; return _BannerKind.none; })); void onExtend() { PricingBottomSheet.showForExtension( context, sessionId: sessionId, // Figma 28โ†’29โ†’30โ†’31: time-up sheet โ†’ confirm popup 1 โ†’ confirm popup // 2 โ†’ closing-message sheet (or skip โ†’ home). The sheet pops itself // before this fires, so the first dialog stacks on the chat route. onEndSession: () => _runEndSessionFlow(context, ref, sessionId), ); } switch (kind) { case _BannerKind.lowTime: return _SoftWarningBanner(mitraName: mitraName, onExtend: onExtend); case _BannerKind.expired: return ChatExpiredBanner(mitraName: mitraName, onExtend: onExtend); case _BannerKind.none: return const SizedBox.shrink(); } } } // Orchestrates the two-step confirm popup chain that follows the time-up // sheet's "cukup, akhiri sesi" CTA. ConfirmEndStep1/2 already pop themselves // before invoking the action callback (see HaloPopup), so we can chain // without manual Navigator.pop calls. The skip path closes the session via // the closure notifier and routes home; the "tulis pesan" path delegates to // the ClosingMessageSheet which calls closeSession itself in its onCompleted. Future _runEndSessionFlow( BuildContext context, WidgetRef ref, String sessionId, ) async { await ConfirmEndStep1.show( context, onConfirm: () async { if (!context.mounted) return; await ConfirmEndStep2.show( context, onWriteMessage: () { if (!context.mounted) return; ClosingMessageSheet.show( context, sessionId: sessionId, onCompleted: () { if (context.mounted) context.go('/home'); }, ); }, onSkip: () async { await ref .read(sessionClosureProvider.notifier) .closeSession(sessionId); if (context.mounted) context.go('/home'); }, ); }, ); } // โ”€โ”€โ”€ Header (back ยท orb ยท name+status ยท timer pill) + progress bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class _ChatHeader extends ConsumerStatefulWidget { final String mitraName; final VoidCallback onBack; const _ChatHeader({required this.mitraName, required this.onBack}); @override ConsumerState<_ChatHeader> createState() => _ChatHeaderState(); } class _ChatHeaderState extends ConsumerState<_ChatHeader> { // Progress-bar denominator. ChatConnectedData doesn't carry the session's // total duration, so we infer it as the max remaining we've seen since // mount. First tick after a fresh connect is effectively `total`; later // extensions raise it back up. int? _observedTotalSeconds; @override Widget build(BuildContext context) { final remainingSeconds = ref.watch(chatRemainingSecondsProvider).valueOrNull; // Only the `isOtherTyping` field of the chat state matters here. `.select` // means this widget rebuilds only when that boolean flips, not on every // message / status update. final isOtherTyping = ref.watch(chatProvider.select( (s) => s is ChatConnectedData && s.isOtherTyping, )); if (remainingSeconds != null && remainingSeconds > 0 && (_observedTotalSeconds == null || remainingSeconds > _observedTotalSeconds!)) { _observedTotalSeconds = remainingSeconds; } final totalSeconds = _observedTotalSeconds; final lowTime = remainingSeconds != null && remainingSeconds > 0 && remainingSeconds < 120; final progress = (remainingSeconds != null && totalSeconds != null && totalSeconds > 0) ? (remainingSeconds / totalSeconds).clamp(0.0, 1.0) : null; return Container( color: HaloTokens.surface, child: Column( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: HaloTokens.border)), ), child: Row( children: [ _CircleIconButton(icon: Icons.chevron_left, onTap: widget.onBack), const SizedBox(width: 12), _MitraOrb(seed: widget.mitraName.hashCode), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.mitraName, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14, fontWeight: FontWeight.w600, color: HaloTokens.ink, ), ), const SizedBox(height: 2), Row( children: [ Container( width: 6, height: 6, decoration: const BoxDecoration( color: HaloTokens.success, shape: BoxShape.circle, ), ), const SizedBox(width: 4), Text( isOtherTyping ? 'online ยท ngetik...' : 'online', style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 11, color: HaloTokens.success, ), ), ], ), ], ), ), if (remainingSeconds != null && remainingSeconds > 0) _TimerPill(seconds: remainingSeconds, lowTime: lowTime), ], ), ), // Progress bar (3px) below the header if (progress != null) Container( height: 3, color: HaloTokens.border, child: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: progress, child: AnimatedContainer( duration: const Duration(seconds: 1), color: lowTime ? const Color(0xFFFF8848) : HaloTokens.brand, ), ), ), ], ), ); } } class _CircleIconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; const _CircleIconButton({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return InkResponse( onTap: onTap, radius: 22, child: Container( width: 36, height: 36, decoration: const BoxDecoration( color: HaloTokens.brandSofter, shape: BoxShape.circle, ), alignment: Alignment.center, child: Icon(icon, color: HaloTokens.brandDark, size: 22), ), ); } } /// Stand-in for the Figma `HBOrb` gradient avatar. Memory tracks this as a /// Phase-4 follow-up โ€” for now a deterministic two-stop gradient circle keeps /// the same visual weight without depending on the unported component. class _MitraOrb extends StatelessWidget { final int seed; const _MitraOrb({required this.seed}); @override Widget build(BuildContext context) { final palette = _palettes[seed.abs() % _palettes.length]; return Container( width: 36, height: 36, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: palette, ), ), ); } static const _palettes = >[ [Color(0xFFE17A9D), Color(0xFFF7B26A)], [Color(0xFFB8DBC8), Color(0xFFE17A9D)], [Color(0xFFD4C5E8), Color(0xFFF7B26A)], [Color(0xFFF7B26A), Color(0xFFE17A9D)], ]; } class _TimerPill extends StatelessWidget { final int seconds; final bool lowTime; const _TimerPill({required this.seconds, required this.lowTime}); @override Widget build(BuildContext context) { final mm = (seconds ~/ 60).toString().padLeft(2, '0'); final ss = (seconds % 60).toString().padLeft(2, '0'); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: lowTime ? const Color(0xFFFFF0E5) : HaloTokens.brandSofter, borderRadius: BorderRadius.circular(12), border: Border.all( color: lowTime ? const Color(0xFFFFB088) : HaloTokens.brandSoft, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( 'SISA WAKTU', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 9.5, fontWeight: FontWeight.w600, letterSpacing: 0.5, color: lowTime ? const Color(0xFFA8410E) : HaloTokens.brandDark, ), ), const SizedBox(height: 2), Text( '$mm:$ss', style: TextStyle( fontFamily: HaloTokens.fontMono, fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: -0.3, color: lowTime ? const Color(0xFFA8410E) : HaloTokens.brandDark, ), ), ], ), ); } } // โ”€โ”€โ”€ Messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class _MessageBubble extends StatelessWidget { final ChatMessage msg; final bool isMe; const _MessageBubble({required this.msg, required this.isMe}); @override Widget build(BuildContext context) { final hh = msg.createdAt.hour.toString().padLeft(2, '0'); final mm = msg.createdAt.minute.toString().padLeft(2, '0'); final bubbleColor = isMe ? HaloTokens.brand : HaloTokens.surface; final textColor = isMe ? Colors.white : HaloTokens.ink; final timeColor = isMe ? Colors.white70 : HaloTokens.inkMuted; 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.78, ), decoration: BoxDecoration( color: bubbleColor, borderRadius: BorderRadius.only( topLeft: const Radius.circular(18), topRight: const Radius.circular(18), bottomLeft: Radius.circular(isMe ? 18 : 4), bottomRight: Radius.circular(isMe ? 4 : 18), ), boxShadow: isMe ? null : [ BoxShadow( color: HaloTokens.brandDark.withValues(alpha: 0.06), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( msg.content, style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14, height: 1.45, color: textColor, ), ), const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text( '$hh:$mm', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 10, color: timeColor, ), ), if (isMe) ...[ const SizedBox(width: 4), _StatusIcon(status: msg.status), ], ], ), ], ), ), ); } } class _StatusIcon extends StatelessWidget { final String status; const _StatusIcon({required this.status}); @override Widget build(BuildContext context) { 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(); } } } /// Three-dot animated typing pill, rendered as a bestie-side message bubble. class _TypingPill extends StatefulWidget { const _TypingPill(); @override State<_TypingPill> createState() => _TypingPillState(); } class _TypingPillState extends State<_TypingPill> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(18), topRight: Radius.circular(18), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(18), ), boxShadow: [ BoxShadow( color: HaloTokens.brandDark.withValues(alpha: 0.06), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { return Padding( padding: EdgeInsets.only(right: i == 2 ? 0 : 4), child: AnimatedBuilder( animation: _ctrl, builder: (_, __) { final phase = (_ctrl.value + i * 0.2) % 1.0; final t = phase < 0.4 ? phase / 0.4 : 1 - (phase - 0.4) / 0.6; final opacity = 0.3 + (0.7 * t.clamp(0.0, 1.0)); return Container( width: 6, height: 6, decoration: BoxDecoration( color: HaloTokens.brand.withValues(alpha: opacity), shape: BoxShape.circle, ), ); }, ), ); }), ), ); } } // โ”€โ”€โ”€ 2-minute soft-warning banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class _SoftWarningBanner extends StatelessWidget { final String mitraName; final VoidCallback onExtend; const _SoftWarningBanner({required this.mitraName, required this.onExtend}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFFFF0E5), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFFFD8B8)), ), child: Row( children: [ const Text('โณ', style: TextStyle(fontSize: 16)), const SizedBox(width: 10), Expanded( child: RichText( text: TextSpan( style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 12, height: 1.4, color: Color(0xFF7A3E08), ), children: [ const TextSpan( text: 'habis... ', style: TextStyle(fontWeight: FontWeight.w700), ), TextSpan(text: 'mau lanjutin curhat sama $mitraName?'), ], ), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: onExtend, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFFF8848), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: const Size(0, 32), elevation: 0, textStyle: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 12, fontWeight: FontWeight.w600, ), ), child: const Text('+30 menit'), ), ], ), ); } } // โ”€โ”€โ”€ Input bar (+ button ยท rounded field ยท send arrow) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class _InputBar extends StatelessWidget { final TextEditingController controller; final ValueChanged onChanged; final VoidCallback onSend; const _InputBar({ required this.controller, required this.onChanged, required this.onSend, }); @override Widget build(BuildContext context) { return Container( color: HaloTokens.surface, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ // `+` attachment โ€” placeholder (no attachment flow yet in this phase). _CircleIconButton(icon: Icons.add, onTap: () {}), const SizedBox(width: 8), Expanded( child: SizedBox( height: 40, child: Material( color: HaloTokens.bg, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, // Center wraps the (intentionally collapsed) TextField so it // sits vertically centered in the 40px pill โ€” without it the // field anchors to the top because `isCollapsed: true` zeroes // out the decoration's vertical padding, and // `textAlignVertical` is a no-op on a collapsed field. child: Center( child: TextField( controller: controller, onChanged: onChanged, textInputAction: TextInputAction.send, onSubmitted: (_) => onSend(), maxLines: 1, textAlignVertical: TextAlignVertical.center, style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14, color: HaloTokens.ink, ), // The app-wide InputDecorationTheme (halo_theme.dart) ships // form-style defaults โ€” filled white, 64px min-height, brand // focused border. None of those are wanted on the chat input // pill, so override every relevant property explicitly here // rather than rely on `border: none` (which only nukes the // default border, not focused/enabled variants or the fill). decoration: const InputDecoration( filled: false, fillColor: Colors.transparent, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, disabledBorder: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(horizontal: 16), constraints: BoxConstraints(), hintText: 'tulis sesuatu...', hintStyle: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14, color: HaloTokens.inkMuted, ), ), ), ), ), ), ), const SizedBox(width: 8), InkResponse( onTap: onSend, radius: 22, child: Container( width: 40, height: 40, decoration: const BoxDecoration( color: HaloTokens.brand, shape: BoxShape.circle, ), alignment: Alignment.center, child: const Icon(Icons.arrow_upward, color: Colors.white, size: 18), ), ), ], ), ); } } class _EncryptedFooter extends StatelessWidget { const _EncryptedFooter(); @override Widget build(BuildContext context) { return Container( width: double.infinity, color: HaloTokens.surface, padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), alignment: Alignment.centerRight, child: const Text( 'terenkripsi ยท gak disimpan ๐Ÿ”’', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 11, color: HaloTokens.inkMuted, ), ), ); } }