Phase 3: session-end UX overhaul + closing-grace cleanup
Promotes the customer-side chat WebSocket to active-session-scoped (driven by a new `activeSessionProvider`) so home reflects session state in real time without a per-screen connection. Backend now auto-completes sessions left in `closing` after a 5-minute grace window so abandoned goodbye flows don't leave the customer's home permanently locked. Customer: - New `activeSessionProvider` (replaces `unread_notifier`) — single source of truth for the active session + unread count; polled every 15s. - Chat WS lifecycle moved to `main.dart` listener on activeSessionProvider. Chat screen joins via `connectIfNotConnected`; the new `refreshSessionStatus` reconciles flags from the server when re-entering an already-connected session (covers missed `sessionClosing`/`sessionExpired` WS events). - Home filters `closing` from the "Sesi Aktif" CTA so a session pending goodbye doesn't block "Mulai Curhat". - Timer-expired UX is a non-dismissible modal (Tutup / Perpanjang) instead of an inline bar. - Early-end goodbye composer gets an amber "Sesi telah ditutup oleh Bestie" banner. Goodbye TextEditingController lifted to state so focus changes no longer wipe the message. - Closure provider reset on chat_screen mount to avoid stale `ClosureCompleteData` from a previous session leaking into a new view. - Chat history now lists `closing` sessions with a "Belum ditutup" badge that routes to the live chat (goodbye composer) instead of the transcript. Mitra: - Same goodbye-controller fix as customer. - Same chat-history badge + routing for `closing` items. Backend: - New `EndedBy.SYSTEM_AUTO_CLOSE` constant. - `startClosureGraceTimer` extracted in `session-timer.service.js`; wired in from `closure.initiateEarlyEnd`, `extension.rejectExtension`, and `extension.handleExtensionTimeout`. Cancelled when customer submits goodbye. - Restart recovery (`restoreActiveTimers`) re-arms grace timers and stamps any orphaned `closing` rows with `system_auto_close`. - `getCustomerHistory` / `getMitraHistory` include `closing` alongside `completed`; ordering uses `COALESCE(ended_at, created_at)`. Removed: dead `session_active_screen.dart` (no router entry). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
client_app/lib/core/chat/active_session_notifier.dart
Normal file
89
client_app/lib/core/chat/active_session_notifier.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'active_session_notifier.g.dart';
|
||||
|
||||
/// Snapshot of the current customer's active session (if any) plus its
|
||||
/// unread-message count. Backed by `/api/client/chat/session/active-with-unread`.
|
||||
///
|
||||
/// Lives at app-scope so the home CTA, the global chat WebSocket lifecycle
|
||||
/// (see `main.dart`), and any badge consumers all share one source of truth.
|
||||
class ActiveSessionData {
|
||||
final Map<String, dynamic>? session;
|
||||
final int unreadCount;
|
||||
|
||||
const ActiveSessionData({this.session, this.unreadCount = 0});
|
||||
|
||||
bool get hasSession => session != null;
|
||||
String? get sessionId => session?['id'] as String?;
|
||||
String get mitraName =>
|
||||
(session?['mitra_display_name'] as String?) ?? 'Bestie';
|
||||
|
||||
static const empty = ActiveSessionData();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ActiveSession extends _$ActiveSession {
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
Future<ActiveSessionData> build() async {
|
||||
ref.onDispose(_stopPolling);
|
||||
_startPolling();
|
||||
return await _fetch();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_stopPolling();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) async {
|
||||
try {
|
||||
final next = await _fetch();
|
||||
state = AsyncData(next);
|
||||
} catch (_) {
|
||||
// Keep last known state on transient failures.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
Future<ActiveSessionData> _fetch() async {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/client/chat/session/active-with-unread');
|
||||
final data = response['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
return ActiveSessionData(
|
||||
session: data,
|
||||
unreadCount: data['unread_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
return ActiveSessionData.empty;
|
||||
}
|
||||
|
||||
/// Force a fresh fetch (e.g. on resume, after pairing succeeds, after a
|
||||
/// session ends). Updates `state` synchronously to AsyncLoading-with-prev
|
||||
/// then to AsyncData.
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final next = await _fetch();
|
||||
state = AsyncData(next);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic local-only update: clear unread without a round-trip
|
||||
/// (used after the chat screen marks messages read).
|
||||
void markRead() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null || !current.hasSession) return;
|
||||
state = AsyncData(ActiveSessionData(
|
||||
session: current.session,
|
||||
unreadCount: 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
26
client_app/lib/core/chat/active_session_notifier.g.dart
Normal file
26
client_app/lib/core/chat/active_session_notifier.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'active_session_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$activeSessionHash() => r'8d7dec22d94b3efc32e0401b9a82a5f712e6cc16';
|
||||
|
||||
/// See also [ActiveSession].
|
||||
@ProviderFor(ActiveSession)
|
||||
final activeSessionProvider =
|
||||
AsyncNotifierProvider<ActiveSession, ActiveSessionData>.internal(
|
||||
ActiveSession.new,
|
||||
name: r'activeSessionProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$activeSessionHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ActiveSession = AsyncNotifier<ActiveSessionData>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -6,6 +6,7 @@ import '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import '../auth/auth_bridge.dart';
|
||||
import '../constants.dart';
|
||||
import 'active_session_notifier.dart';
|
||||
|
||||
part 'chat_notifier.g.dart';
|
||||
|
||||
@@ -102,13 +103,64 @@ class Chat extends _$Chat {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _typingTimer;
|
||||
String? _connectedSessionId;
|
||||
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
ChatData build() => const ChatInitialData();
|
||||
|
||||
/// Idempotent connect: if we're already connected to [sessionId], refresh
|
||||
/// the session status from the server (in case it transitioned to closing /
|
||||
/// expired while we weren't watching closely) but reuse the live WS.
|
||||
/// If we're connected to a different session, disconnect first.
|
||||
Future<void> connectIfNotConnected(String sessionId) async {
|
||||
// ignore: avoid_print
|
||||
print('[CONNECT_IF_NOT] target=$sessionId existing=$_connectedSessionId state=${state.runtimeType}');
|
||||
if (_connectedSessionId == sessionId &&
|
||||
(state is ChatConnectedData || state is ChatConnectingData)) {
|
||||
await refreshSessionStatus(sessionId);
|
||||
return;
|
||||
}
|
||||
if (_connectedSessionId != null && _connectedSessionId != sessionId) {
|
||||
_cleanup();
|
||||
}
|
||||
await connect(sessionId);
|
||||
}
|
||||
|
||||
/// Re-pull session status from the server and reconcile local flags. Used
|
||||
/// when re-entering the chat screen for a session we're already connected
|
||||
/// to — covers the case where a `sessionClosing` / `sessionExpired` WS event
|
||||
/// was missed (e.g. socket dropped, app backgrounded, status changed before
|
||||
/// we connected).
|
||||
Future<void> refreshSessionStatus(String sessionId) async {
|
||||
final current = state;
|
||||
// ignore: avoid_print
|
||||
print('[REFRESH_STATUS] called sessionId=$sessionId currentState=${current.runtimeType}');
|
||||
if (current is! ChatConnectedData) return;
|
||||
try {
|
||||
final info = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
||||
final data = info['data'] as Map<String, dynamic>?;
|
||||
final status = data?['status'] as String?;
|
||||
// ignore: avoid_print
|
||||
print('[REFRESH_STATUS] backend status=$status');
|
||||
if (status == SessionStatus.completed ||
|
||||
status == SessionStatus.cancelled ||
|
||||
status == SessionStatus.expired) {
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
return;
|
||||
}
|
||||
state = current.copyWith(
|
||||
sessionClosing: status == SessionStatus.closing,
|
||||
);
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[REFRESH_STATUS] error=$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(String sessionId) async {
|
||||
_connectedSessionId = sessionId;
|
||||
state = const ChatConnectingData();
|
||||
|
||||
try {
|
||||
@@ -174,6 +226,8 @@ class Chat extends _$Chat {
|
||||
state = const ChatInitialData();
|
||||
}
|
||||
|
||||
String? get connectedSessionId => _connectedSessionId;
|
||||
|
||||
void sendMessage(String content) {
|
||||
if (state is! ChatConnectedData || _channel == null) return;
|
||||
final current = state as ChatConnectedData;
|
||||
@@ -317,6 +371,9 @@ class Chat extends _$Chat {
|
||||
|
||||
case WsMessage.sessionCompleted:
|
||||
_cleanup();
|
||||
// Home CTA must clear immediately — backend just told us the session
|
||||
// is gone, so refresh the shared snapshot.
|
||||
ref.invalidate(activeSessionProvider);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionTopicUpdated:
|
||||
@@ -336,5 +393,6 @@ class Chat extends _$Chat {
|
||||
_channel = null;
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = null;
|
||||
_connectedSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
|
||||
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
|
||||
|
||||
/// See also [Chat].
|
||||
@ProviderFor(Chat)
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import 'active_session_notifier.dart';
|
||||
|
||||
part 'session_closure_notifier.g.dart';
|
||||
|
||||
@@ -68,10 +69,14 @@ class SessionClosure extends _$SessionClosure {
|
||||
'message': message,
|
||||
});
|
||||
state = const ClosureCompleteData();
|
||||
// The session is now completed server-side; refresh home snapshot so the
|
||||
// CTA returns to "Mulai Curhat" without waiting for the next poll tick.
|
||||
ref.invalidate(activeSessionProvider);
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'SESSION_NOT_ACTIVE' || e.response?.statusCode == 409) {
|
||||
state = const ClosureCompleteData();
|
||||
ref.invalidate(activeSessionProvider);
|
||||
} else {
|
||||
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
|
||||
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'unread_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class UnreadCount extends _$UnreadCount {
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
int build() {
|
||||
_startPolling();
|
||||
ref.onDispose(_stopPolling);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_stopPolling();
|
||||
_fetchUnreadCount();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
_fetchUnreadCount();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _fetchUnreadCount() async {
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/client/chat/session/active-with-unread');
|
||||
final data = response['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
state = data['unread_count'] as int? ?? 0;
|
||||
} else {
|
||||
state = 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void markRead() {
|
||||
state = 0;
|
||||
}
|
||||
|
||||
void refresh() => _fetchUnreadCount();
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'unread_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$unreadCountHash() => r'6a0b31b86ae616177f54346392d9675f916a7bec';
|
||||
|
||||
/// See also [UnreadCount].
|
||||
@ProviderFor(UnreadCount)
|
||||
final unreadCountProvider = NotifierProvider<UnreadCount, int>.internal(
|
||||
UnreadCount.new,
|
||||
name: r'unreadCountProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$unreadCountHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$UnreadCount = Notifier<int>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -6,6 +6,7 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import '../auth/auth_bridge.dart';
|
||||
import '../chat/active_session_notifier.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
part 'pairing_notifier.g.dart';
|
||||
@@ -148,6 +149,11 @@ class Pairing extends _$Pairing {
|
||||
final sessionId = data['session_id'] as String;
|
||||
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
|
||||
|
||||
// A session now exists for this customer — refresh the shared snapshot
|
||||
// so the home CTA reflects it immediately when the user returns.
|
||||
// ignore: unawaited_futures
|
||||
ref.read(activeSessionProvider.notifier).refresh();
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
|
||||
} else if (type == SessionStatus.expired) {
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a';
|
||||
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
Reference in New Issue
Block a user