Files
halobestie-clone/client_app/lib/features/chat/screens/chat_screen.dart
Ramadhan Sjamsani 3a0cdf5c4e Phase 5/6 polish: end-session flow, notif sound on API 33+, Xendit webview
Customer end-of-session (figma §6):
- PricingBottomSheet: ghost "cukup, akhiri sesi" CTA + dedup divider
- chat_screen._runEndSessionFlow chains ConfirmEndStep1 → ConfirmEndStep2
  → ClosingMessageSheet (or "lewati saja" → close + /home). The four
  popup/sheet widgets already existed; this commit just wires them
- showModalBottomSheet: showDragHandle=false to suppress the Material 3
  auto-injected handle that was stacking with our own pill

Notification sound on API 33+:
- Bump channel halobestie_chat_v1 → halobestie_chat_v2, created from
  native Kotlin in MainActivity.kt with AudioAttributes contentType
  CONTENT_TYPE_SONIFICATION. flutter_local_notifications' default of
  CONTENT_TYPE_UNKNOWN was causing Android 13 to silently drop audio
  focus while the notification still posted (isNoisy=true). Both apps
- Backend FCM payload channelId updated to v2
- AndroidManifest meta-data: default_notification_icon + color → brand
  silhouette tinted pink instead of generic Android bell. Both apps

Customer pairing reliability:
- pairing_notifier: applyPairedFromPush({sessionId, mitraName}) unsticks
  searching screen when WS push failed and FCM/active-session-poll is
  the first signal. Idempotent across PairingSearchingData,
  PairingTargetedWaitingData, PairingErrorData (covers ALREADY_ACTIVE)
- notification_service: dispatches every FCM data payload to an
  onDataMessage callback (foreground + tap + cold-start). main.dart
  wires that to applyPairedFromPush on type=='paired'. Foreground
  'paired' no longer renders a local banner — screen self-advances
- main.dart activeSession listener also calls applyPairedFromPush when
  a session appears server-side while pairing is in a waiting state.
  Covers stale ALREADY_ACTIVE recovery without a full page refresh

Auth refresh token race:
- auth_notifier._refreshFromStorage shares a single in-flight Future
  across all callers (Auth.build + 401-retry path). Backend rotates
  refresh tokens, so concurrent callers using the same stored token
  would race → loser 401s → catch wipes flutter_secure_storage → user
  appears logged out after kill+reopen

Polish:
- method_pick_screen: resizeToAvoidBottomInset=false — prevents the
  one-frame overflow when entering with the previous screen's keyboard
  still animating out
- bestie_history: BestieHistoryItem now carries `status` (backend
  already returns it). Removed _rawHistoryProvider that fetched the
  same endpoint just to read status; the two providers could go out
  of sync mid-rebuild and throw RangeError(length) on indexing

Xendit Stage 8 (carried from WIP):
- xendit_checkout_screen: embedded webview hosting Xendit's invoice
  page (intercepts halobestie:// deeplink + return-page URLs for
  deterministic pop)
- waiting_payment_screen: auto-pushes the webview when the backend
  payload includes xendit_invoice_url; spinner card + "Buka ulang
  halaman pembayaran" CTA for the QR-fallback path
- pubspec: webview_flutter ^4.13.0

Maestro infra:
- subflows/onboarding_returning_user: drop the "Mulai" carousel wait
  (splash auto-advances since 2026-05-26); tap phone-field hint
  instead of point; drop hideKeyboard (sends BACK → /home when the
  IME isn't actually up)
- New flow ts-customer-06-01-end_session_via_timeup_sheet: drives
  the full path to the chat-expired banner. Last step blocked by a
  Maestro+Flutter gesture quirk on the perpanjang ElevatedButton
  (raw `adb input tap` works at the same coords). Documented in
  memory; deeplink fixture or manual verify recommended
- ChatExpiredBanner button wrapped with Semantics(identifier:
  'chat_extend_button', button: true, onTap: …) — good hygiene for
  future tests even though it doesn't fix the dadb tap issue

.dev/: tracked wsl_emulator_bridge.ps1 + wsl_tcp_relay.py for
Maestro-on-WSL setup (Windows-side netsh portproxy + WSL-side
loopback relays). Both referenced from existing CLAUDE.md notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:45:46 +08:00

1153 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/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() {
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,
),
),
);
}
}