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:
@@ -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(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user