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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user