diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index 0076732..d78e029 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -7,6 +7,7 @@ import { peekStubOtp } from '../../services/otp.service.js' import { expirePairingRequest } from '../../services/pairing.service.js' +import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js' import { getDb } from '../../db/client.js' import { PairingFailureCause, SessionStatus } from '../../constants.js' @@ -108,4 +109,49 @@ export const internalTestRoutes = async (fastify) => { await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE) return { ok: true, session_id: target } }) + + // Force-set the expires_at of an active chat_session to drive Phase 4 + // Stage 6 countdown UX (3-min snackbar, last-2-min danger, expired banner) + // without waiting in real time. Reschedules the in-memory session timer so + // `session_warning` / `session_timer` / `session_expired` WS events fire on + // the new schedule. + // + // Body shape: + // { seconds_from_now: 175 } → expire latest active session in N seconds + // { session_id: '', seconds_from_now } → expire specific session + fastify.post('/force-session-expires-at', async (request, reply) => { + const { session_id, seconds_from_now } = request.body ?? {} + if (typeof seconds_from_now !== 'number') { + return reply.code(400).send({ error: 'seconds_from_now (number) required' }) + } + let target = session_id + if (!target) { + const [row] = await sql` + SELECT id FROM chat_sessions + WHERE status = ${SessionStatus.ACTIVE} + ORDER BY created_at DESC + LIMIT 1 + ` + if (!row) { + return reply.code(404).send({ error: 'no_active_session' }) + } + target = row.id + } + const [updated] = await sql` + UPDATE chat_sessions + SET expires_at = NOW() + (${seconds_from_now} || ' seconds')::interval + WHERE id = ${target} AND status = ${SessionStatus.ACTIVE} + RETURNING id, expires_at + ` + if (!updated) { + return reply.code(404).send({ error: 'no_active_session_for_id', session_id: target }) + } + // Allow the 3-min warning to fire again on the new schedule. + _resetThreeMinFiredForTest(updated.id) + startSessionTimer(updated.id, updated.expires_at) + // Push an immediate WS resync so the customer UI's local ticker tracks + // the new schedule without waiting for the next scheduled event. + _broadcastTimerResyncForTest(updated.id, updated.expires_at) + return { ok: true, session_id: updated.id, expires_at: updated.expires_at } + }) } diff --git a/backend/src/services/session-timer.service.js b/backend/src/services/session-timer.service.js index 68be78d..1d45581 100644 --- a/backend/src/services/session-timer.service.js +++ b/backend/src/services/session-timer.service.js @@ -12,6 +12,33 @@ const sql = getDb() // (e.g. extension reschedule). This matches the Phase 4 spec: "fire once". const sessionTimers = new Map() +/** + * Dev/test-only — clear the per-session "3-min warning already fired" flag so + * the warning can fire again after `force-session-expires-at` reschedules a + * session backwards. Production code never needs this. + */ +export const _resetThreeMinFiredForTest = (sessionId) => { + const timers = sessionTimers.get(sessionId) + if (timers) timers.threeMinFired = false +} + +/** + * Dev/test-only — push an immediate WS resync of the timer state so a Maestro + * flow can drive the customer UI through the danger pill / expired banner + * states without waiting for the next scheduled tick. Production code drives + * UX off the scheduled `session_timer` / `session_warning` / `session_expired` + * events instead. + */ +export const _broadcastTimerResyncForTest = (sessionId, expiresAt) => { + const remaining = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)) + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.SESSION_TIMER, + remaining_seconds: remaining, + expires_at: expiresAt, + session_id: sessionId, + }) +} + export const startSessionTimer = (sessionId, expiresAt) => { const now = Date.now() const expiresMs = new Date(expiresAt).getTime() @@ -89,15 +116,23 @@ const onSessionWarning = (sessionId) => { /** * Phase 4 — 3-min warning. Customer-only event (mitra has no countdown UI). * Idempotent per session via the `threeMinFired` flag captured by startSessionTimer. + * + * Includes `remaining_seconds` and `expires_at` so the client can resync its + * local ticker against the server's view of when the session ends. The + * customer-side ticker drives the last-2-min danger pill + expired banner, + * neither of which the server emits a discrete event for. */ -const onThreeMinuteWarning = (sessionId) => { +const onThreeMinuteWarning = async (sessionId) => { const timers = sessionTimers.get(sessionId) if (timers?.threeMinFired) return // belt-and-braces — should not happen if (timers) timers.threeMinFired = true + const [row] = await sql`SELECT expires_at FROM chat_sessions WHERE id = ${sessionId}` sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.SESSION_WARNING, kind: 'three_minutes_left', session_id: sessionId, + remaining_seconds: 180, + expires_at: row?.expires_at ?? null, }) } diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index d92213f..83430ae 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -149,15 +149,20 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti } export const getSessionById = async (sessionId) => { + // `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1. + // The chat header pill needs it, so surface it on every session.info read. + // Falls back to 'chat' for pre-3.7 rows where payment_session_id is null. const [session] = await sql` SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes, + COALESCE(ps.mode, 'chat') AS mode, c.display_name AS customer_display_name, m.display_name AS mitra_display_name FROM chat_sessions cs INNER JOIN customers c ON c.id = cs.customer_id LEFT JOIN mitras m ON m.id = cs.mitra_id + LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id WHERE cs.id = ${sessionId} ` return session diff --git a/client_app/.maestro/flows/06_chat_countdown.yaml b/client_app/.maestro/flows/06_chat_countdown.yaml new file mode 100644 index 0000000..7c0b00a --- /dev/null +++ b/client_app/.maestro/flows/06_chat_countdown.yaml @@ -0,0 +1,74 @@ +# Stage 6 acceptance: drive a live chat session through the countdown UX +# in one run. +# +# Flow: +# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first). +# 2. Force expires_at = now + 175s → backend fires `session_warning` at 175s +# (180s threshold, fudge 5s for clock drift) within ~1s. +# 3. Verify the 3-min snackbar copy renders. +# 4. Force expires_at = now + 90s → timer pill flips to danger styling at +# remaining <= 120s (well within the 90s window). +# 5. Force expires_at = now + 0s → expired banner appears above input bar. +# +# Pre-req: +# 1. A live chat session is on screen (paired + active). The simplest way is +# to chain this after flow 03_payment_to_chat_happy.yaml. +# 2. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'. +# +# Run (chained): +# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \ +# client_app/.maestro/flows/06_chat_countdown.yaml +appId: ${APP_ID_ANDROID} +env: + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +- launchApp: + clearState: false + +# Step 0: assert we're already on the chat screen (input hint is the landmark). +- extendedWaitUntil: + visible: + text: "Ketik Pesan" + timeout: 10000 + +# Step 1: force expires_at to 175s — fires the 3-min warning within ~1s. +- runScript: + file: ../scripts/force_session_expires_at.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + SECONDS_FROM_NOW: "175" + +# Step 2: verify the 3-min snackbar. +- extendedWaitUntil: + visible: + text: "sisa 3 menit lagi" + timeout: 5000 + +# Step 3: force expires_at to 90s — last-2-min danger pill territory. +- runScript: + file: ../scripts/force_session_expires_at.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + SECONDS_FROM_NOW: "90" + +# Step 4: assert the danger-styled timer pill renders. The pill content is a +# minutes-and-seconds string ("1m Xd"); we only assert the unit suffix here +# because the exact seconds drift between assertion and render. +- extendedWaitUntil: + visible: + text: "1m" + timeout: 5000 + +# Step 5: force expires_at to 0s — expired banner appears. +- runScript: + file: ../scripts/force_session_expires_at.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + SECONDS_FROM_NOW: "0" + +# Step 6: verify the floating expired banner + perpanjang CTA. +- extendedWaitUntil: + visible: + text: "waktu curhat habis" + timeout: 8000 +- assertVisible: "perpanjang" diff --git a/client_app/.maestro/scripts/force_session_expires_at.js b/client_app/.maestro/scripts/force_session_expires_at.js new file mode 100644 index 0000000..80b5cc4 --- /dev/null +++ b/client_app/.maestro/scripts/force_session_expires_at.js @@ -0,0 +1,21 @@ +// Force-set the expires_at of the most-recent ACTIVE chat_session by hitting +// the dev-only /internal/_test/force-session-expires-at endpoint. Used by the +// Stage 6 maestro flow (06_chat_countdown.yaml) to drive the 3-min snackbar, +// last-2-min danger pill, and expired banner without waiting in real time. +// +// Reads BACKEND_INTERNAL_URL + SECONDS_FROM_NOW from env (Maestro injects them +// from the flow). The backend re-runs startSessionTimer with the new schedule +// AND clears the per-session "3-min warning fired" flag so the warning fires +// again on the new schedule. +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const seconds = parseInt(SECONDS_FROM_NOW || '175', 10) +const resp = http.post(`${url}/internal/_test/force-session-expires-at`, { + body: JSON.stringify({ latest: true, seconds_from_now: seconds }), + headers: { 'Content-Type': 'application/json' }, +}) +if (resp.status !== 200) { + throw new Error(`force-session-expires-at failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.SESSION_ID = data.session_id +output.EXPIRES_AT = data.expires_at diff --git a/client_app/lib/core/chat/chat_notifier.dart b/client_app/lib/core/chat/chat_notifier.dart index 4520d3c..e3d1dfd 100644 --- a/client_app/lib/core/chat/chat_notifier.dart +++ b/client_app/lib/core/chat/chat_notifier.dart @@ -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? 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? 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 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.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.broadcast(); + Stream 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; @@ -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: diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart index c667b2a..6c17cf3 100644 --- a/client_app/lib/core/chat/chat_notifier.g.dart +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -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.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; +String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b'; /// See also [Chat]. @ProviderFor(Chat) diff --git a/client_app/lib/core/constants.dart b/client_app/lib/core/constants.dart index 3743554..fde84c0 100644 --- a/client_app/lib/core/constants.dart +++ b/client_app/lib/core/constants.dart @@ -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'; diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index b1d65b4..f35d54f 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -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 { final _goodbyeController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; + StreamSubscription? _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 { 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 { _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 { } }); + // 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 { 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 { 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 { 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(), ], diff --git a/client_app/lib/features/chat/widgets/chat_expired_banner.dart b/client_app/lib/features/chat/widgets/chat_expired_banner.dart new file mode 100644 index 0000000..9632e65 --- /dev/null +++ b/client_app/lib/features/chat/widgets/chat_expired_banner.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; + +/// Floating banner injected above the chat input bar when the session timer +/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6: +/// gives the customer a soft, in-place way to extend instead of the modal-only +/// flow from Phase 3. +class ChatExpiredBanner extends StatelessWidget { + final VoidCallback onExtend; + + const ChatExpiredBanner({super.key, required this.onExtend}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB( + HaloSpacing.s12, + HaloSpacing.s8, + HaloSpacing.s12, + HaloSpacing.s8, + ), + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s16, + HaloSpacing.s12, + HaloSpacing.s12, + HaloSpacing.s12, + ), + decoration: const BoxDecoration( + color: HaloTokens.danger, + borderRadius: HaloRadius.lg, + boxShadow: HaloShadows.card, + ), + child: Row( + children: [ + const Text('⏰', style: TextStyle(fontSize: 20)), + const SizedBox(width: HaloSpacing.s12), + const Expanded( + child: Text( + 'waktu curhat habis', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + HaloButton( + label: 'perpanjang', + size: HaloButtonSize.sm, + variant: HaloButtonVariant.secondary, + onPressed: onExtend, + ), + ], + ), + ); + } +} diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index f4bcc24..e7baf1b 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -3,15 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; +import '../../payment/state/payment_draft_provider.dart'; -/// Extension-only pricing sheet. +/// Extension-only pricing sheet — Phase 4 Stage 6 layout. /// /// Used solely for in-session extension requests; the initial pairing flow /// goes through `/payment` instead. Free-trial is never offered for extensions. /// -/// Submit triggers [SessionClosure.requestExtension], which internally -/// runs the payment-session create+confirm and then the extend POST. -class PricingBottomSheet extends ConsumerWidget { +/// Mirrors the Stage 3 duration-pick screen: chat/call toggle on top, +/// 5-option tier list below, single CTA at the bottom. The `perpanjang` +/// behavior is unchanged from Phase 3.7 — submit calls +/// [SessionClosure.requestExtension], which runs the payment-session +/// create+confirm and then the extend POST. +class PricingBottomSheet extends ConsumerStatefulWidget { /// Required — the in-progress chat session id this extension targets. final String extensionSessionId; @@ -22,62 +28,372 @@ class PricingBottomSheet extends ConsumerWidget { return showModalBottomSheet( context: context, isScrollControlled: true, + backgroundColor: HaloTokens.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), builder: (_) => PricingBottomSheet(extensionSessionId: sessionId), ); } @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PricingBottomSheetState(); +} + +class _PricingBottomSheetState extends ConsumerState { + PaymentMode _mode = PaymentMode.chat; + String? _selectedDurationId; + + List _tiersForMode(PricingData pricing) { + // Phase 4 — chat/call tier groups. Falls back to legacy `tiers` when the + // backend hasn't been cut over yet (so the sheet still works locally + // against an old backend). + if (_mode == PaymentMode.call) { + return pricing.callTiers.isNotEmpty ? pricing.callTiers : pricing.tiers; + } + return pricing.chatTiers.isNotEmpty ? pricing.chatTiers : pricing.tiers; + } + + void _onTierTap(PriceTier tier) { + setState(() { + _selectedDurationId = tier.id ?? tier.durationMinutes.toString(); + }); + } + + void _onConfirm(PriceTier tier) { + Navigator.of(context).pop(); + ref.read(sessionClosureProvider.notifier).requestExtension( + widget.extensionSessionId, + durationMinutes: tier.durationMinutes, + price: tier.price, + ); + } + + @override + Widget build(BuildContext context) { final pricingAsync = ref.watch(chatPricingProvider); - return pricingAsync.when( - loading: () => const SizedBox( - height: 200, - child: Center(child: CircularProgressIndicator()), - ), - error: (error, _) => const SizedBox( - height: 200, - child: Center(child: Text('Gagal memuat harga. Coba lagi.')), - ), - data: (pricing) => DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.8, - expand: false, - builder: (_, scrollController) { - return Padding( - padding: const EdgeInsets.all(24), - child: ListView( - controller: scrollController, - children: [ - const Text( - 'Perpanjang Durasi', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - // No free-trial path for extensions. - ...pricing.tiers.map((tier) => Card( - child: ListTile( - title: Text(tier.label), - trailing: Text( - formatRupiah(tier.price), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - onTap: () { - Navigator.of(context).pop(); - ref.read(sessionClosureProvider.notifier).requestExtension( - extensionSessionId, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); - }, - ), - )), - ], + return DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.5, + maxChildSize: 0.92, + expand: false, + builder: (_, scrollController) { + return SafeArea( + top: false, + child: pricingAsync.when( + loading: () => const SizedBox( + height: 240, + child: Center(child: CircularProgressIndicator()), ), - ); - }, + error: (_, __) => const SizedBox( + height: 240, + child: Center(child: Text('Gagal memuat harga. Coba lagi.')), + ), + data: (pricing) => _Body( + pricing: pricing, + mode: _mode, + selectedDurationId: _selectedDurationId, + tiers: _tiersForMode(pricing), + scrollController: scrollController, + onModeChanged: (m) => setState(() { + _mode = m; + _selectedDurationId = null; + }), + onTierTap: _onTierTap, + onConfirm: _onConfirm, + ), + ), + ); + }, + ); + } +} + +class _Body extends StatelessWidget { + final PricingData pricing; + final PaymentMode mode; + final String? selectedDurationId; + final List tiers; + final ScrollController scrollController; + final ValueChanged onModeChanged; + final ValueChanged onTierTap; + final ValueChanged onConfirm; + + const _Body({ + required this.pricing, + required this.mode, + required this.selectedDurationId, + required this.tiers, + required this.scrollController, + required this.onModeChanged, + required this.onTierTap, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) { + final selectedTier = tiers.firstWhere( + (t) => (t.id ?? t.durationMinutes.toString()) == selectedDurationId, + orElse: () => const PriceTier(durationMinutes: 0, price: 0, label: ''), + ); + final hasSelection = selectedTier.durationMinutes > 0; + final ctaLabel = hasSelection + ? '${mode == PaymentMode.call ? '📞' : '💬'} perpanjang ${formatRupiah(selectedTier.price)}' + : 'pilih durasi dulu'; + + return Column( + children: [ + const SizedBox(height: HaloSpacing.s8), + Container( + width: 40, + height: 4, + decoration: const BoxDecoration( + color: HaloTokens.border, + borderRadius: HaloRadius.pill, + ), + ), + const SizedBox(height: HaloSpacing.s12), + const Text( + 'waktu curhat habis', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + const SizedBox(height: HaloSpacing.s4), + const Text( + 'mau tambah waktu?', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24), + child: _ModeToggle(mode: mode, onChanged: onModeChanged), + ), + const SizedBox(height: HaloSpacing.s12), + Expanded( + child: tiers.isEmpty + ? const _EmptyState() + : ListView.separated( + controller: scrollController, + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s4, + HaloSpacing.s24, + HaloSpacing.s16, + ), + itemCount: tiers.length, + separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s8), + itemBuilder: (context, i) { + final tier = tiers[i]; + final id = tier.id ?? tier.durationMinutes.toString(); + final selected = id == selectedDurationId; + return _TierCard( + tier: tier, + selected: selected, + onTap: () => onTierTap(tier), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s24, + ), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: HaloTokens.border)), + ), + child: HaloButton( + label: ctaLabel, + size: HaloButtonSize.lg, + fullWidth: true, + onPressed: hasSelection ? () => onConfirm(selectedTier) : null, + ), + ), + ], + ); + } +} + +class _ModeToggle extends StatelessWidget { + final PaymentMode mode; + final ValueChanged onChanged; + + const _ModeToggle({required this.mode, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.pill, + ), + child: Row( + children: [ + Expanded(child: _Pill(label: 'chat', selected: mode == PaymentMode.chat, onTap: () => onChanged(PaymentMode.chat))), + Expanded(child: _Pill(label: 'call', selected: mode == PaymentMode.call, onTap: () => onChanged(PaymentMode.call))), + ], + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + const _Pill({required this.label, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? HaloTokens.surface : Colors.transparent, + borderRadius: HaloRadius.pill, + child: InkWell( + borderRadius: HaloRadius.pill, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s8), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: selected ? HaloTokens.brandDark : HaloTokens.inkSoft, + ), + ), + ), + ), + ); + } +} + +class _TierCard extends StatelessWidget { + final PriceTier tier; + final bool selected; + final VoidCallback onTap; + + const _TierCard({required this.tier, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? HaloTokens.brandSofter : HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: onTap, + child: AnimatedContainer( + duration: HaloMotion.fast, + padding: const EdgeInsets.all(HaloSpacing.s16), + decoration: BoxDecoration( + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: selected ? 2 : 1, + ), + borderRadius: HaloRadius.lg, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: const BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.md, + ), + alignment: Alignment.center, + child: Text( + '${tier.durationMinutes}', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Row( + children: [ + Text( + '${tier.durationMinutes} menit', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + if (tier.tag != null) ...[ + const SizedBox(width: HaloSpacing.s8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s8, + vertical: 2, + ), + decoration: const BoxDecoration( + color: HaloTokens.mint, + borderRadius: HaloRadius.pill, + ), + child: Text( + tier.tag!, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF1F4D34), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + ), + Text( + formatRupiah(tier.price), + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(HaloSpacing.s24), + child: Text( + 'Belum ada paket untuk mode ini.', + style: TextStyle(color: HaloTokens.inkSoft), + ), ), ); } diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index 47c3ec4..4b94860 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -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 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? 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().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.'); diff --git a/mitra_app/lib/core/constants.dart b/mitra_app/lib/core/constants.dart index c406468..8ecbf58 100644 --- a/mitra_app/lib/core/constants.dart +++ b/mitra_app/lib/core/constants.dart @@ -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'; diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index e6041da..bcfc8da 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -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 { 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 { ); } + 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(