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

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
@@ -32,6 +33,12 @@ class ChatConnectedData extends ChatData {
final bool sessionClosing;
final bool goodbyeSubmitted;
final Map<String, dynamic>? extensionResponse;
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
final SessionMode mode;
// Phase 4 — drives the client-side seconds-left ticker. Backend only emits
// discrete `session_timer` (60s) + `session_warning` (180s) events, so we
// tick locally off this absolute timestamp for the danger pill / banner.
final DateTime? expiresAt;
const ChatConnectedData({
required this.messages,
@@ -42,6 +49,8 @@ class ChatConnectedData extends ChatData {
this.sessionClosing = false,
this.goodbyeSubmitted = false,
this.extensionResponse,
this.mode = SessionMode.chat,
this.expiresAt,
});
ChatConnectedData copyWith({
@@ -53,6 +62,8 @@ class ChatConnectedData extends ChatData {
bool? sessionClosing,
bool? goodbyeSubmitted,
Map<String, dynamic>? extensionResponse,
SessionMode? mode,
DateTime? expiresAt,
}) {
return ChatConnectedData(
messages: messages ?? this.messages,
@@ -63,6 +74,8 @@ class ChatConnectedData extends ChatData {
sessionClosing: sessionClosing ?? this.sessionClosing,
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
extensionResponse: extensionResponse ?? this.extensionResponse,
mode: mode ?? this.mode,
expiresAt: expiresAt ?? this.expiresAt,
);
}
}
@@ -102,6 +115,25 @@ class ChatMessage {
}
}
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
@riverpod
Stream<int> chatRemainingSeconds(Ref ref) async* {
final chatState = ref.watch(chatProvider);
if (chatState is! ChatConnectedData) return;
final expiresAt = chatState.expiresAt;
if (expiresAt == null) return;
while (true) {
final remaining = expiresAt.difference(DateTime.now()).inSeconds;
yield remaining < 0 ? 0 : remaining;
if (remaining <= 0) return;
await Future<void>.delayed(const Duration(seconds: 1));
}
}
@Riverpod(keepAlive: true)
class Chat extends _$Chat {
WebSocketChannel? _channel;
@@ -109,10 +141,22 @@ class Chat extends _$Chat {
Timer? _typingTimer;
String? _connectedSessionId;
// Phase 4 — broadcast stream of `session_warning.kind` strings (e.g.
// `three_minutes_left`). Screens listen via [warningStream] to fire one-shot
// UI like the 3-min snackbar. Kept separate from state so the warning
// doesn't accidentally re-fire on rebuild.
final _warningController = StreamController<String>.broadcast();
Stream<String> get warningStream => _warningController.stream;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
ChatData build() => const ChatInitialData();
ChatData build() {
ref.onDispose(() {
_warningController.close();
});
return const ChatInitialData();
}
/// Idempotent connect: if we're already connected to [sessionId], refresh
/// the session status from the server (in case it transitioned to closing /
@@ -155,11 +199,16 @@ class Chat extends _$Chat {
return;
}
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(data?['mode'] as String?);
final expiresAtRaw = data?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith(
sessionClosing: status == SessionStatus.closing,
sessionPaused: status == SessionStatus.extending,
sessionExpired: false,
goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
);
} catch (e) {
// ignore: avoid_print
@@ -184,6 +233,9 @@ class Chat extends _$Chat {
final isClosing = sessionStatus == SessionStatus.closing;
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(sessionData?['mode'] as String?);
final expiresAtRaw = sessionData?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>;
@@ -225,6 +277,8 @@ class Chat extends _$Chat {
messages: messages,
sessionClosing: isClosing,
goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
);
} catch (e) {
state = const ChatErrorData('Gagal terhubung ke chat.');
@@ -351,11 +405,41 @@ class Chat extends _$Chat {
case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?;
state = current.copyWith(remainingSeconds: remaining);
// When the server includes expires_at (Phase 4 dev resync + future
// periodic ticks), update the local ticker reference. Backwards-
// compatible: pre-Phase-4 events without `expires_at` are no-ops here.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith(
remainingSeconds: remaining,
expiresAt: expiresAt,
);
break;
case WsMessage.sessionWarning:
// Forward to listeners (chat screen drives a one-shot snackbar). Stream
// is broadcast — subscribers may or may not be present; cheap if not.
final kind = data['kind'] as String?;
// Resync the local ticker — server may have shifted expires_at since
// we last connected (e.g. extension, dev shortcut). Without this, the
// last-2-min danger pill / expired banner can't track real time.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
if (expiresAt != null) {
state = current.copyWith(expiresAt: expiresAt);
}
if (kind != null) _warningController.add(kind);
break;
case WsMessage.sessionExpired:
state = current.copyWith(sessionExpired: true);
// Snap the local ticker to 0 so the floating expired banner appears
// immediately. The server-side expires_at may have shifted (e.g.
// dev /force-session-expires-at) ahead of our last refresh, so we
// can't rely on the existing expiresAt value to reach 0 on its own.
state = current.copyWith(
sessionExpired: true,
expiresAt: DateTime.now(),
);
break;
case WsMessage.sessionPaused:

View File

@@ -6,7 +6,31 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
String _$chatRemainingSecondsHash() =>
r'd7bce1bffe7d3034b6f4905194ead4dfaf473c92';
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
///
/// Copied from [chatRemainingSeconds].
@ProviderFor(chatRemainingSeconds)
final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
chatRemainingSeconds,
name: r'chatRemainingSecondsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$chatRemainingSecondsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -64,6 +64,21 @@ class ExtensionStatus {
ExtensionStatus._();
}
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
/// no real audio transport is built yet.
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'),
@@ -101,6 +116,9 @@ class WsMessage {
static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed';
// Phase 4 — soft countdown warning (`kind: 'three_minutes_left'`).
// Customer-only: mitra never sees a countdown.
static const sessionWarning = 'session_warning';
// Extension
static const extensionRequest = 'extension_request';