Files
halobestie-clone/client_app/lib/features/chat/screens/chat_screen.dart
Ramadhan Sjamsani eeb4ea38fc feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
  payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
  extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
  /payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
  (/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:26 +08:00

1156 lines
39 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.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
/// 150284) + `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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _messageController = TextEditingController();
final _goodbyeController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
StreamSubscription<String>? _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<void> _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<String> 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() {
// ignore: discarded_futures
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
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<void> _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 = <List<Color>>[
[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<String> 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,
),
),
);
}
}