Files
halobestie-clone/client_app/lib/features/chat/screens/chat_screen.dart
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

1105 lines
37 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/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/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() {
PricingBottomSheet.showForExtension(context, sessionId: 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();
}
}
}
// ─── 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,
),
),
);
}
}