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

@@ -13,6 +13,9 @@ import '../../../core/constants.dart';
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);
class MitraChatScreen extends ConsumerStatefulWidget {
final String sessionId;
@@ -117,7 +120,23 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: () => context.pop(),
),
title: Text(widget.customerName),
title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.customerName,
overflow: TextOverflow.ellipsis,
),
),
if (chatState is MitraChatConnectedData &&
chatState.mode == SessionMode.call) ...[
const SizedBox(width: 8),
_buildVoiceCallPill(),
],
],
),
actions: [
if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState),
if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
@@ -145,6 +164,24 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
);
}
Widget _buildVoiceCallPill() {
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,
),
),
);
}
Widget _buildSensitivityHeader() {
const theme = SensitivityTheme.sensitive;
return Container(