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

@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/chat/unread_notifier.dart';
import '../../core/chat/active_session_notifier.dart';
import '../../core/pairing/pairing_notifier.dart';
import '../chat/widgets/pricing_bottom_sheet.dart';
import '../chat/widgets/topic_selection_bottom_sheet.dart';
@@ -16,14 +15,10 @@ class HomeScreen extends ConsumerStatefulWidget {
}
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
Map<String, dynamic>? _activeSession;
bool _loadingSession = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkActiveSession();
}
@override
@@ -35,29 +30,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkActiveSession();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkActiveSession();
}
Future<void> _checkActiveSession() async {
try {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/session/active');
final data = response['data'];
if (mounted) {
setState(() {
_activeSession = data is Map<String, dynamic> ? data : null;
_loadingSession = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingSession = false);
// Re-fetch in case a session ended/started while backgrounded.
ref.read(activeSessionProvider.notifier).refresh();
}
}
@@ -71,6 +45,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authData = authState.valueOrNull;
final activeSessionAsync = ref.watch(activeSessionProvider);
final displayName = switch (authData) {
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
@@ -112,27 +87,31 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
if (_loadingSession)
const CircularProgressIndicator()
else if (_activeSession != null)
_ActiveSessionCard(
session: _activeSession!,
onTap: () {
final sessionId = _activeSession!['id'] as String;
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
context.push('/chat/session/$sessionId', extra: mitraName);
},
)
else ...[
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => _onStartChatPressed(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
activeSessionAsync.when(
loading: () => const CircularProgressIndicator(),
error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)),
data: (snapshot) {
// Hide the "Sesi Aktif" CTA when the session is in `closing`
// — the conversation is over, only the goodbye composer
// remains. Backend auto-completes such sessions after a
// grace period; until then the user shouldn't be invited
// back into them from home.
final status = snapshot.session?['status'] as String?;
final isCurhatable = snapshot.hasSession && status != 'closing';
if (isCurhatable) {
return _ActiveSessionCard(
mitraName: snapshot.mitraName,
unreadCount: snapshot.unreadCount,
onTap: () {
final sessionId = snapshot.sessionId;
if (sessionId == null) return;
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
},
);
}
return _StartChatButton(onPressed: () => _onStartChatPressed(context));
},
),
],
),
),
@@ -141,17 +120,40 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
}
class _ActiveSessionCard extends ConsumerWidget {
final Map<String, dynamic> session;
final VoidCallback onTap;
const _ActiveSessionCard({required this.session, required this.onTap});
class _StartChatButton extends StatelessWidget {
final VoidCallback onPressed;
const _StartChatButton({required this.onPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
final unreadCount = ref.watch(unreadCountProvider);
Widget build(BuildContext context) {
return Column(
children: [
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: onPressed,
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
);
}
}
class _ActiveSessionCard extends StatelessWidget {
final String mitraName;
final int unreadCount;
final VoidCallback onTap;
const _ActiveSessionCard({
required this.mitraName,
required this.unreadCount,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: InkWell(