Phase 4 Stage 6: chat-room countdown UX + voice-call mode pill

Customer chat screen:
- Voice-call header pill (mode == 'call' renders accent-colored pill;
  chat mode renders no pill).
- HaloSnackbar fires once per session at 180s remaining ('sisa 3 menit
  lagi ya 🤍'), driven by the backend session_warning WS event.
- Last-2-min danger styling: timer pill flips to HaloTokens.danger +
  bold JetBrainsMono when remaining <= 120s.
- Floating ChatExpiredBanner widget injected above the input bar when
  remaining hits 0 in closing-grace state. perpanjang -> existing
  pricing bottom sheet.
- pricing_bottom_sheet.dart rewritten to the 5-option layout with
  chat|call mode toggle (mirrors duration-pick from Stage 3).

Mitra chat screen: voice-call header pill only (no countdown UX per PRD).

Backend:
- session.service.js getSessionById JOINs payment_sessions so mode +
  expires_at ship in /api/shared/chat/:id/info.
- session-timer.service.js onThreeMinuteWarning now emits expires_at +
  remaining_seconds for client resync.
- Dev-only POST /internal/_test/force-session-expires-at clears the
  3-min flag, reschedules the timer, and broadcasts WS resync. Lets
  the Maestro flow drive 175s -> 90s -> 0s without waiting live.

New chatRemainingSeconds StreamProvider derived from expiresAt, fed by
session_warning / session_timer / session_expired resync messages
(plan referenced a secondsLeftProvider that didn't actually exist).

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

Out of scope: meet.google.com URL launching - url_launcher isn't a
client_app dependency and message bubbles render plain Text. Defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:25:11 +08:00
parent f170d54535
commit 14b5cc966b
14 changed files with 902 additions and 75 deletions

View File

@@ -6,6 +6,9 @@ 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/chat_expired_banner.dart';
import '../widgets/pricing_bottom_sheet.dart';
// Chat theme colors
@@ -31,9 +34,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final _goodbyeController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
StreamSubscription<String>? _warningSub;
bool _showBestieBanner = true;
bool _showUserBanner = true;
bool _expiredDialogShown = false;
// Per-session-mount idempotency flag for the 3-min snackbar. The backend
// also guards once-per-session (timers.threeMinFired), but a fresh mount
// could still receive the event on a refreshed status pull, so we belt-
// and-braces here.
bool _threeMinShown = false;
@override
void initState() {
@@ -48,6 +57,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
ref.read(sessionClosureProvider.notifier).reset();
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId);
});
// Subscribe to the chat notifier's session-warning stream. Using stream
// subscription rather than a `ref.listen` on state because the warning is
// a one-shot signal, not a persistent state field.
_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
@@ -56,6 +78,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
_goodbyeController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
_warningSub?.cancel();
super.dispose();
// Intentionally do NOT disconnect the WS here. The global lifecycle in
// `App` decides when to disconnect (logout / no active session).
@@ -178,6 +201,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
});
// Phase 4 — derived ticker drives the danger pill / expired banner.
// Only watched when there's a connected session with a known expires_at.
final remainingAsync = ref.watch(chatRemainingSecondsProvider);
final remainingTick = remainingAsync.value;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -193,29 +221,75 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: _exitChat,
),
title: Text(widget.mitraName),
actions: [
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${chatState.remainingSeconds}s',
style: TextStyle(
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
),
),
title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.mitraName,
overflow: TextOverflow.ellipsis,
),
),
if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[
const SizedBox(width: 8),
_buildVoiceCallPill(),
],
],
),
actions: [
if (chatState is ChatConnectedData && remainingTick != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(child: _buildTimerPill(remainingTick)),
),
],
),
body: _buildBody(chatState, closureState),
body: _buildBody(chatState, closureState, remainingTick),
),
);
}
Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
Widget _buildVoiceCallPill() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
color: HaloTokens.accent,
borderRadius: HaloRadius.pill,
),
child: const Text(
'📞 Voice Call',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
);
}
Widget _buildTimerPill(int remaining) {
final danger = remaining <= 120;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: danger ? HaloTokens.danger : Colors.transparent,
borderRadius: HaloRadius.pill,
),
child: Text(
formatCountdown(remaining),
style: TextStyle(
fontFamily: HaloTokens.fontMono,
fontSize: 13,
fontWeight: danger ? FontWeight.w700 : FontWeight.w600,
color: danger ? Colors.white : HaloTokens.ink,
),
),
);
}
Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) {
if (chatState is ChatConnectingData) {
return const Center(child: CircularProgressIndicator());
}
@@ -223,12 +297,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return Center(child: Text(chatState.message));
}
if (chatState is ChatConnectedData) {
return _buildChatBody(chatState, closureState);
return _buildChatBody(chatState, closureState, remainingTick);
}
return const SizedBox.shrink();
}
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) {
// Show goodbye composer when closure flow is in goodbye/submitting OR when
// we mounted directly into a `closing` session (e.g. opened from history).
// The chatProvider listener can't catch this case because it only fires on
@@ -303,6 +377,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
),
// Floating expired banner — visible while the timer has hit zero
// and the session hasn't been finalized yet (still in closing
// grace). Tapping `perpanjang` opens the time-up sheet, same as
// the modal route.
if (remainingTick != null && remainingTick <= 0)
ChatExpiredBanner(
onExtend: () => PricingBottomSheet.showForExtension(
context,
sessionId: widget.sessionId,
),
),
// Input bar — disabled when timer expired (modal handles next step)
if (!state.sessionExpired) _buildInputBar(),
],