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

@@ -36,6 +36,8 @@ class MitraChatConnectedData extends MitraChatData {
// info-only — does not affect matching, pricing, or routing. Sourced from
// `chat_sessions.topics` via the session info payload.
final List<String> topics;
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
final SessionMode mode;
const MitraChatConnectedData({
required this.messages,
@@ -47,6 +49,7 @@ class MitraChatConnectedData extends MitraChatData {
this.extensionRequest,
this.topicSensitivity = TopicSensitivity.regular,
this.topics = const [],
this.mode = SessionMode.chat,
});
MitraChatConnectedData copyWith({
@@ -60,6 +63,7 @@ class MitraChatConnectedData extends MitraChatData {
bool clearExtensionRequest = false,
TopicSensitivity? topicSensitivity,
List<String>? topics,
SessionMode? mode,
}) {
return MitraChatConnectedData(
messages: messages ?? this.messages,
@@ -71,6 +75,7 @@ class MitraChatConnectedData extends MitraChatData {
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
topics: topics ?? this.topics,
mode: mode ?? this.mode,
);
}
}
@@ -137,6 +142,7 @@ class MitraChat extends _$MitraChat {
final isClosing = sessionStatus == SessionStatus.closing;
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?);
final sessionMode = SessionMode.fromString(sessionData?['mode'] as String?);
final rawTopics = sessionData?['topics'];
final espTopics = rawTopics is List
? rawTopics.whereType<String>().toList(growable: false)
@@ -184,6 +190,7 @@ class MitraChat extends _$MitraChat {
goodbyeSubmitted: goodbyeSubmittedByMe,
topicSensitivity: sessionTopic,
topics: espTopics,
mode: sessionMode,
);
} catch (e) {
state = const MitraChatErrorData('Gagal terhubung ke chat.');

View File

@@ -62,6 +62,20 @@ enum RequestResponse {
}
}
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
/// (added in Phase 4 stage 1). Mitra only reads this to render the header
/// "Voice Call" pill — there is no functional difference from a regular chat.
enum SessionMode {
chat('chat'),
call('call');
final String value;
const SessionMode(this.value);
static SessionMode fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => SessionMode.chat);
}
/// Session topic sensitivity
enum TopicSensitivity {
regular('regular'),
@@ -112,6 +126,8 @@ class WsMessage {
static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed';
// Phase 4 — `session_warning` is customer-only; the mitra never receives it.
// Kept here for symmetry with backend constants only.
// Extension
static const extensionRequest = 'extension_request';