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