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:
2026-04-25 20:47:24 +08:00
parent b59c66f7df
commit f8380163bc
22 changed files with 540 additions and 327 deletions

View 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,
));
}
}

View 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

View File

@@ -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;
}
}

View File

@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -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.');
}

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -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();
}

View File

@@ -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