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:
@@ -48,7 +48,10 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final s = _sessions[index];
|
||||
final sessionId = s['id'] as String;
|
||||
final customerName = s['customer_display_name'] as String? ?? 'Customer';
|
||||
final status = s['status'] as String?;
|
||||
final isClosing = status == 'closing';
|
||||
final endedAt = s['ended_at'] != null
|
||||
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||
: null;
|
||||
@@ -66,6 +69,10 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
||||
const SizedBox(width: 8),
|
||||
SensitivityBadge(sensitivity: topic, fontSize: 10),
|
||||
],
|
||||
if (isClosing) ...[
|
||||
const SizedBox(width: 8),
|
||||
const _OutstandingClosureBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text([
|
||||
@@ -74,10 +81,36 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
||||
if (closureMsg != null) '"$closureMsg"',
|
||||
].join(' - ')),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/chat/history/${s['id']}'),
|
||||
onTap: () => isClosing
|
||||
? context.push('/chat/session/$sessionId', extra: customerName)
|
||||
: context.push('/chat/history/$sessionId'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutstandingClosureBadge extends StatelessWidget {
|
||||
const _OutstandingClosureBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber.shade700, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
'Belum ditutup',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.amber.shade900,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user