Mitra Bestie §1–§3: shell + Undangan + popup + chat polish

Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:14:30 +08:00
parent fcb8eaa505
commit fbc94daac7
59 changed files with 5039 additions and 687 deletions

View File

@@ -8,14 +8,16 @@ import '../../../core/chat/sensitivity_config_provider.dart';
import '../../../core/chat/widgets/sensitivity_badge.dart';
import '../../../core/chat/widgets/sensitivity_theme.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_orb.dart';
// Chat theme colors
const _kUserBubbleColor = Color(0xFFD4929A);
const _kBannerColor = Color(0xFFC4868F);
const _kAccentPink = Color(0xFFBE7C8A);
// Phase 4 — voice-call mode badge background. Mirrors `HaloTokens.accent`
// from the customer app palette so both apps render the same pill color.
const _kVoiceCallPillColor = Color(0xFFF7B26A);
// Mitra bubble gradient — matches `BestieChatV5` (figma-bestie/.../v5.jsx:282)
// pink → purple direction (135deg ≈ topLeft → bottomRight).
const _kMitraBubbleGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFC44979), Color(0xFF8B5CF6)],
);
class MitraChatScreen extends ConsumerStatefulWidget {
final String sessionId;
@@ -93,7 +95,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
// Parent build runs ONCE per lifecycle — there are no ref.watch calls here.
// State changes (messages, typing, status updates, mode flip, sensitivity
// flip) all rebuild only the leaf consumers that watch them:
// - _MitraChatVoicePill → mode flag (via .select)
// - _MitraChatSubtitle → mode + sessionExpired (via .select)
// - _MitraChatTopicToggle → topicSensitivity (via .select) + config
// - _MitraChatTimerAction → mitraChatRemainingSecondsProvider
// - _MitraChatBodyContent → full chatProvider + extensionProvider
@@ -117,27 +119,48 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
});
// Orb seed derived from the sessionId — stable per session, identical
// to the convention used by `undangan_screen.dart` for incoming requests.
// We don't have a separate customerId in this screen scope (only name +
// sessionId), and the spec explicitly allows hashing sessionId here.
final orbSeed = widget.sessionId.hashCode;
return Scaffold(
backgroundColor: HaloTokens.bg,
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: HaloTokens.surface,
foregroundColor: HaloTokens.ink,
elevation: 0.5,
centerTitle: true,
centerTitle: false,
titleSpacing: 0,
leading: IconButton(
icon: const Icon(Icons.chevron_left, size: 28),
icon: const Icon(Icons.chevron_left, size: 28, color: HaloTokens.ink),
onPressed: () => context.pop(),
),
title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
HaloOrb(size: 38, seed: orbSeed),
const SizedBox(width: 10),
Flexible(
child: Text(
widget.customerName,
overflow: TextOverflow.ellipsis,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.customerName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
height: 1.2,
),
),
const _MitraChatSubtitle(),
],
),
),
const _MitraChatVoicePill(),
],
),
actions: [
@@ -158,45 +181,36 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
}
/// AppBar voice-call mode badge. Watches only the `mode` field of the chat
/// state — the conditional collapses to a bool via `.select`, so this widget
/// rebuilds only when the mode actually flips (essentially never during a
/// session) and the surrounding AppBar stays still on every message/typing
/// state change.
class _MitraChatVoicePill extends ConsumerWidget {
const _MitraChatVoicePill();
/// AppBar subtitle line. Watches only the `mode` flag and `sessionExpired`
/// flag (each via `.select`), so a WS message / typing / status update never
/// rebuilds this widget — only an actual mode flip or expiry transition does.
/// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:239): "sesi aktif · Chat",
/// "sesi aktif · Voice", or "sesi berakhir" (color #A8410E when ended).
class _MitraChatSubtitle extends ConsumerWidget {
const _MitraChatSubtitle();
static const _endedInk = Color(0xFFA8410E);
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCall = ref.watch(mitraChatProvider.select(
(s) => s is MitraChatConnectedData && s.mode == SessionMode.call,
));
if (!isCall) return const SizedBox.shrink();
return const Padding(
padding: EdgeInsets.only(left: 8),
child: _VoiceCallPillBody(),
);
}
}
class _VoiceCallPillBody extends StatelessWidget {
const _VoiceCallPillBody();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
color: _kVoiceCallPillColor,
borderRadius: BorderRadius.all(Radius.circular(9999)),
),
child: const Text(
'📞 Voice Call',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
final ended = ref.watch(mitraChatProvider.select(
(s) => s is MitraChatConnectedData && s.sessionExpired,
));
final text = ended
? 'sesi berakhir'
: isCall
? 'sesi aktif · Voice'
: 'sesi aktif · Chat';
return Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: ended ? _endedInk : HaloTokens.inkSoft,
height: 1.2,
),
);
}
@@ -331,9 +345,6 @@ class _MitraChatBodyContent extends ConsumerStatefulWidget {
}
class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
bool _showBestieBanner = true;
bool _showUserBanner = true;
// Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic`
// enum's `label` property — we only need to read these here, not write.
static const Map<String, String> _espTopicLabels = {
@@ -422,70 +433,92 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
return _buildAwaitingCustomerGoodbyeView(state);
}
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
// Background: flat cream (HaloTokens.bg) per Figma `BestieChatV5`. Drop
// the previous `chat_pattern.png` wallpaper layer. Sensitivity tint still
// applies for the sensitive case — it's a working feature; for regular
// sessions we use the flat brand bg so the cream/Figma look comes through.
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final bg = isSensitive
? SensitivityTheme.sensitive.bgTint
: HaloTokens.bg;
// Build list payload: leading "sesi dimulai" system pill, optional ESP
// topic chip row, then the message bubbles. Single source of truth for
// index → item resolution so the ListView builder stays simple.
final hasTopics = state.topics.isNotEmpty;
// [0]=system pill, [1]=topics (optional), then messages.
final leadingCount = 1 + (hasTopics ? 1 : 0);
return Stack(
children: [
// Background pattern
Positioned.fill(
child: Container(
color: bgTint,
child: Image.asset(
'assets/images/chat_pattern.png',
repeat: ImageRepeat.repeat,
fit: BoxFit.none,
return Container(
color: bg,
child: Column(
children: [
// Session-ended banner (mirrors `BestieChatV5` ended-state notice in
// figma-bestie/.../v5.jsx:294). Pinned directly under the header so
// it's visible regardless of scroll position.
if (state.sessionExpired) _buildSessionEndedBanner(),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
itemCount: state.messages.length + leadingCount,
itemBuilder: (context, index) {
if (index == 0) return _buildSystemPill('sesi dimulai');
if (hasTopics && index == 1) {
return _buildTopicChipsRow(state.topics);
}
final msg = state.messages[index - leadingCount];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
),
// Typing indicator
if (state.isOtherTyping)
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 6),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Customer sedang mengetik...',
style: TextStyle(color: HaloTokens.inkMuted, fontSize: 12),
),
),
),
// Input bar — softer disabled-state notice once the timer hits
// zero. Tunggu klien perpanjang / tutup obrolan; we don't accept
// new mitra messages on an expired session.
if (state.sessionExpired)
_buildEndedInputNotice()
else
_buildInputBar(),
],
),
);
}
/// Centered system-message pill. Replaces the legacy bracketed entry
/// banners. Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:255).
Widget _buildSystemPill(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Align(
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.pill,
),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: HaloTokens.brand,
),
),
),
// Content
Column(
children: [
// Entry banners
if (_showBestieBanner)
_buildEntryBanner(
'[Bestie] Sudah Memasuki Ruangan',
() => setState(() => _showBestieBanner = false),
),
if (_showUserBanner)
_buildEntryBanner(
'[User] Sudah Memasuki Ruangan',
() => setState(() => _showUserBanner = false),
),
// Messages — when the customer picked ESP topics during
// onboarding, render a read-only chip row as the first list
// item (above the first message bubble). Info-only.
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length +
(state.topics.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (state.topics.isNotEmpty && index == 0) {
return _buildTopicChipsRow(state.topics);
}
final msgIndex =
state.topics.isNotEmpty ? index - 1 : index;
final msg = state.messages[msgIndex];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
),
// Typing indicator
if (state.isOtherTyping)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
),
// Input bar
_buildInputBar(),
],
),
],
),
);
}
@@ -500,15 +533,15 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFE0CDD1)),
color: HaloTokens.surface,
borderRadius: HaloRadius.pill,
border: Border.all(color: HaloTokens.border),
),
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: _kAccentPink,
color: HaloTokens.brandDark,
fontWeight: FontWeight.w500,
),
),
@@ -518,56 +551,128 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
);
}
Widget _buildEntryBanner(String text, VoidCallback onDismiss) {
Widget _buildSessionEndedBanner() {
const bg = Color(0xFFFFE5E5);
const border = Color(0xFFF5B5B5);
const ink = Color(0xFF7A2828);
return Container(
color: _kBannerColor,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: const BoxDecoration(
color: bg,
border: Border(bottom: BorderSide(color: border)),
),
child: const Row(
children: [
const Icon(Icons.volume_up, color: Colors.white, size: 18),
const SizedBox(width: 8),
Icon(Icons.access_time, color: ink, size: 18),
SizedBox(width: 10),
Expanded(
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)),
),
GestureDetector(
onTap: onDismiss,
child: const Icon(Icons.close, color: Colors.white, size: 18),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Durasi sesi habis. ',
style: TextStyle(fontWeight: FontWeight.w700, color: ink),
),
TextSpan(
text: 'Tunggu klien perpanjang atau tutup obrolan.',
style: TextStyle(color: ink),
),
],
),
style: TextStyle(fontSize: 12.5, height: 1.4),
),
),
],
),
);
}
Widget _buildEndedInputNotice() {
return SafeArea(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: const Text(
'Sesi sudah berakhir 💛',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: HaloTokens.inkSoft,
fontWeight: FontWeight.w500,
),
),
),
);
}
// TODO(stage7): reply-quote bubble polish. Figma `BestieChatV5` renders a
// quote block above the bubble text when a message replies to another
// (2px left accent stripe, original sender name, truncated original text).
// Out of scope for Stage 6 — the `MitraChatMessage` model in
// `core/chat/mitra_chat_notifier.dart` does not carry a `replyTo` payload
// and the backend WS frames don't ship one. Adding it requires changes to
// backend message schema + this notifier + ChatService. Until then there
// are no reply-to messages to render. Corner-radius logic below already
// accepts the future `replyTo` case (top corner becomes 4 when replying).
Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) {
// Tail corner: 4 on the side facing the sender's edge (bottom-right for
// me, bottom-left for them). In stage7 the top corner will also become
// 4 when this message replies to another — see TODO(stage7) above.
final radius = BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
);
final maxW = MediaQuery.of(context).size.width * 0.78;
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.75),
decoration: BoxDecoration(
color: isMe ? _kUserBubbleColor : Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(msg.content, style: const TextStyle(fontSize: 15)),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxW),
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: isMe ? _kMitraBubbleGradient : null,
color: isMe ? null : HaloTokens.surface,
border: isMe ? null : Border.all(color: HaloTokens.border),
borderRadius: radius,
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(msg.status),
child: Text(
msg.content,
style: TextStyle(
fontSize: 13.5,
height: 1.45,
color: isMe ? Colors.white : HaloTokens.ink,
),
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: HaloTokens.inkMuted),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(msg.status),
],
],
],
),
],
),
],
),
),
),
);
@@ -576,56 +681,96 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
Widget _buildStatusIcon(String status) {
switch (status) {
case 'sending':
return const Icon(Icons.access_time, size: 14, color: Colors.white70);
return const Icon(Icons.access_time, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.sent:
return const Icon(Icons.check, size: 14, color: Colors.white70);
return const Icon(Icons.check, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.delivered:
return const Icon(Icons.done_all, size: 14, color: Colors.white70);
return const Icon(Icons.done_all, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.read:
return const Icon(Icons.done_all, size: 14, color: Colors.white);
return const Icon(Icons.done_all, size: 12, color: HaloTokens.brand);
default:
return const SizedBox.shrink();
}
}
Widget _buildInputBar() {
return SafeArea(
child: Container(
padding: const EdgeInsets.all(8),
color: Colors.white,
child: Row(
children: [
Expanded(
child: TextField(
controller: widget.messageController,
onChanged: widget.onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => widget.onSend(),
decoration: InputDecoration(
hintText: 'Ketik Pesan',
hintStyle: TextStyle(color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:305): cream-bg pill text
// field with a round pink send button. SafeArea takes care of the home-
// indicator inset; the JSX uses a hardcoded 30px bottom pad — we let
// SafeArea handle it instead so devices without a home indicator don't
// get a giant gap.
return Container(
decoration: const BoxDecoration(
color: HaloTokens.surface,
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
// Both children rendered inside a Container(height: 44) so their
// visible heights match exactly. Earlier attempt used
// OutlineInputBorder on the TextField directly, but OutlineInputBorder
// sizes to text content (not the parent SizedBox), so the pill came
// out at ~29dp while the round button was 44dp — visual centers
// drifted ~43px apart. This pattern is bulletproof: a Container
// draws the pill outline + bg, a borderless centered TextField sits
// inside, and the send button is the same explicit 44dp Container.
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
color: HaloTokens.bg,
borderRadius: HaloRadius.pill,
border: Border.fromBorderSide(
BorderSide(color: HaloTokens.border),
),
),
child: TextField(
controller: widget.messageController,
onChanged: widget.onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => widget.onSend(),
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(fontSize: 13.5, color: HaloTokens.ink),
decoration: const InputDecoration(
hintText: 'ketik balasan...',
hintStyle: TextStyle(color: HaloTokens.inkMuted, fontSize: 13.5),
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
),
),
const SizedBox(width: 8),
Container(
decoration: const BoxDecoration(
color: _kAccentPink,
shape: BoxShape.circle,
const SizedBox(width: 8),
Container(
width: 44,
height: 44,
decoration: const BoxDecoration(
color: HaloTokens.brand,
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: widget.onSend,
child: const Center(
child: Icon(Icons.arrow_upward, color: Colors.white, size: 18),
),
),
),
),
child: IconButton(
icon: const Icon(Icons.send, color: Colors.white, size: 20),
onPressed: widget.onSend,
),
),
],
],
),
),
),
);
@@ -748,53 +893,45 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
}
Widget _buildAwaitingCustomerGoodbyeView(MitraChatConnectedData state) {
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
return Stack(
children: [
Positioned.fill(
child: Container(
color: bgTint,
child: Image.asset(
'assets/images/chat_pattern.png',
repeat: ImageRepeat.repeat,
fit: BoxFit.none,
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final bg = isSensitive
? SensitivityTheme.sensitive.bgTint
: HaloTokens.bg;
return Container(
color: bg,
child: Column(
children: [
Container(
width: double.infinity,
color: Colors.amber.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Pesan penutupmu sudah terkirim. Menunggu user...',
style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600),
),
),
],
),
),
),
Column(
children: [
Container(
width: double.infinity,
color: Colors.amber.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Pesan penutupmu sudah terkirim. Menunggu user...',
style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600),
),
),
],
),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
),
],
),
],
),
],
),
);
}
}
@@ -804,21 +941,74 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
/// rebuilds *only* this widget (a single Text), not the surrounding AppBar,
/// Scaffold body, message ListView, or input bar. This is the per-second
/// hotspot the wider chat-screen perf work targets.
///
/// Visual mirrors `BestieChatV5` (figma-bestie/.../v5.jsx):
/// - active → amber pill "SISA WAKTU\nmm:ss" (#FFE8D9 bg / #FFCAA0 border)
/// - ended → red pill "SELESAI\n00:00" (#FFE5E5 bg / #F5B5B5 border)
/// "Ended" here is `seconds <= 0`. The session-expired flag lives on
/// `mitraChatProvider` but reading it here would re-couple this widget to
/// the chat state and defeat the per-second perf isolation — so we use the
/// timer reaching zero as a proxy. The red banner below the header uses the
/// authoritative `sessionExpired` flag.
class _MitraChatTimerAction extends ConsumerWidget {
const _MitraChatTimerAction();
static const _activeBg = Color(0xFFFFE8D9);
static const _activeBorder = Color(0xFFFFCAA0);
static const _activeInk = Color(0xFF7A3E08);
static const _endedBg = Color(0xFFFFE5E5);
static const _endedBorder = Color(0xFFF5B5B5);
static const _endedInk = Color(0xFF7A2828);
@override
Widget build(BuildContext context, WidgetRef ref) {
final seconds = ref.watch(mitraChatRemainingSecondsProvider);
if (seconds == null) return const SizedBox.shrink();
// When the first session_timer frame hasn't arrived yet (`seconds == null`)
// show a placeholder pill — matches the Figma which always renders the
// pill — instead of disappearing. Treat null as "loading", not "ended".
final loading = seconds == null;
final ended = !loading && seconds <= 0;
final mm = loading ? '--' : (seconds ~/ 60).clamp(0, 99).toString().padLeft(2, '0');
final ss = loading ? '--' : (seconds % 60).clamp(0, 59).toString().padLeft(2, '0');
final label = ended ? 'SELESAI' : 'SISA WAKTU';
final value = ended ? '00:00' : '$mm:$ss';
final bg = ended ? _endedBg : _activeBg;
final border = ended ? _endedBorder : _activeBorder;
final ink = ended ? _endedInk : _activeInk;
return Padding(
padding: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.only(right: 12),
child: Center(
child: Text(
'${seconds}s',
style: TextStyle(
color: seconds < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: HaloRadius.sm,
border: Border.all(color: border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: ink,
),
),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: ink,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),