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>
199 lines
6.5 KiB
Dart
199 lines
6.5 KiB
Dart
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/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';
|
|
|
|
class HomeScreen extends ConsumerStatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
// Re-fetch in case a session ended/started while backgrounded.
|
|
ref.read(activeSessionProvider.notifier).refresh();
|
|
}
|
|
}
|
|
|
|
Future<void> _onStartChatPressed(BuildContext context) async {
|
|
final topic = await TopicSelectionBottomSheet.show(context);
|
|
if (topic == null || !context.mounted) return;
|
|
await PricingBottomSheet.show(context, topicSensitivity: topic);
|
|
}
|
|
|
|
@override
|
|
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? ?? '',
|
|
AuthAnonymousData d => d.displayName,
|
|
_ => '',
|
|
};
|
|
|
|
ref.listen(pairingProvider, (prev, next) {
|
|
if (next is PairingSearchingData) {
|
|
context.go('/chat/searching');
|
|
} else if (next is PairingNoBestieData) {
|
|
context.go('/chat/no-bestie');
|
|
} else if (next is PairingErrorData) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(next.message)),
|
|
);
|
|
}
|
|
});
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Halo Bestie'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.history),
|
|
onPressed: () => context.push('/chat/history'),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.logout),
|
|
onPressed: () => ref.read(authProvider.notifier).logout(),
|
|
),
|
|
],
|
|
),
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
|
const SizedBox(height: 32),
|
|
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));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartChatButton extends StatelessWidget {
|
|
final VoidCallback onPressed;
|
|
const _StartChatButton({required this.onPressed});
|
|
|
|
@override
|
|
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(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: unreadCount > 0,
|
|
label: Text('$unreadCount'),
|
|
child: const CircleAvatar(
|
|
backgroundColor: Colors.green,
|
|
child: Icon(Icons.chat, color: Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Sesi Aktif',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Sedang curhat dengan $mitraName',
|
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|