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>
1156 lines
39 KiB
Dart
1156 lines
39 KiB
Dart
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
|
||
/// 150–284) + `v3.jsx::HBChatExpiredBanner` (line 423). Phase 4 deltas the
|
||
/// older design had (entry banners, AppBar `akhiri` button, doodle bg) are
|
||
/// dropped — see [requirement/flow_customer.mermaid.md] §5.
|
||
class ChatScreen extends ConsumerStatefulWidget {
|
||
final String sessionId;
|
||
final String mitraName;
|
||
|
||
const ChatScreen({super.key, required this.sessionId, required this.mitraName});
|
||
|
||
@override
|
||
ConsumerState<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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|