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

@@ -26,6 +26,7 @@ class MitraChatScreen extends ConsumerStatefulWidget {
class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final _messageController = TextEditingController();
final _goodbyeController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
bool _showBestieBanner = true;
@@ -43,6 +44,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
void dispose() {
final notifier = ref.read(mitraChatProvider.notifier);
_messageController.dispose();
_goodbyeController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
super.dispose();
@@ -503,7 +505,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
Widget _buildGoodbyeView(ExtensionData extState) {
final controller = TextEditingController();
return SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
@@ -516,7 +517,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
const SizedBox(height: 24),
TextField(
controller: controller,
controller: _goodbyeController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Terima kasih sudah curhat...',
@@ -528,7 +529,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
onPressed: extState is ExtensionSubmittingData
? null
: () {
final text = controller.text.trim();
final text = _goodbyeController.text.trim();
if (text.isNotEmpty) {
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
widget.sessionId, text,