From b9c4841eb1b252a9903230cf531bf1d57d7f4f7a Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 22:16:30 +0800 Subject: [PATCH] Phase 3.2 WS1: Chat request overlay, queue, stale reasons - Backend: add reason field to chat_request_closed WS messages (cancelled_by_customer, accepted_by_other, expired) - Backend: include duration_minutes, is_free_trial in chat_request WS - ChatRequestNotifier: add ChatRequestStaleData, StaleReason enum, request queue (List), ignore(), acknowledgeStale(), _advanceQueue() - New ChatRequestOverlay widget: slides up from bottom, dimmed background, swipe to dismiss, shows active/stale request content - Integrate overlay in main.dart wrapping MaterialApp.router - Cleanup: convert HomeScreen to ConsumerWidget, remove showModalBottomSheet, remove IncomingRequestSheet, remove lifecycle observer Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/services/pairing.service.js | 9 +- .../lib/core/chat/chat_request_notifier.dart | 112 +++++++- .../core/chat/chat_request_notifier.g.dart | 2 +- .../chat/widgets/chat_request_overlay.dart | 266 ++++++++++++++++++ .../chat/widgets/incoming_request_sheet.dart | 98 ------- mitra_app/lib/features/home/home_screen.dart | 59 +--- mitra_app/lib/main.dart | 9 +- 7 files changed, 380 insertions(+), 175 deletions(-) create mode 100644 mitra_app/lib/core/chat/widgets/chat_request_overlay.dart delete mode 100644 mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 3588b8c..4b14a80 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -101,6 +101,8 @@ export const createPairingRequest = async (customerId, { duration_minutes, price type: WsMessage.CHAT_REQUEST, session_id: session.id, created_at: session.created_at, + duration_minutes: session.duration_minutes, + is_free_trial: session.is_free_trial, }) } @@ -202,6 +204,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, + reason: 'accepted_by_other', }) } @@ -245,7 +248,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => { WHERE session_id = ${sessionId} AND response IS NULL ` - // Notify mitras to dismiss + // Notify mitras to dismiss (customer cancelled) const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` @@ -253,6 +256,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, + reason: 'cancelled_by_customer', }) } @@ -283,7 +287,7 @@ export const expirePairingRequest = async (sessionId) => { session_id: sessionId, }) - // Notify mitras to dismiss + // Notify mitras to dismiss (request expired) const notifications = await sql` SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` @@ -291,6 +295,7 @@ export const expirePairingRequest = async (sessionId) => { await notifyMitra(n.mitra_id, { type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, + reason: 'expired', }) } diff --git a/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart index e3f2656..8947ab9 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -11,6 +11,13 @@ import '../notifications/notification_service.dart'; part 'chat_request_notifier.g.dart'; +// Stale reason for dismissed requests +enum StaleReason { + cancelledByCustomer, // "Permintaan dibatalkan oleh customer" + acceptedByOther, // "Permintaan diterima oleh Bestie lain" + expired, // "Permintaan kedaluwarsa" +} + // States sealed class ChatRequestData { const ChatRequestData(); @@ -26,7 +33,21 @@ class ChatRequestListeningData extends ChatRequestData { class ChatRequestIncomingData extends ChatRequestData { final String sessionId; - const ChatRequestIncomingData(this.sessionId); + final int? durationMinutes; + final bool? isFreeTrial; + final DateTime? createdAt; + const ChatRequestIncomingData( + this.sessionId, { + this.durationMinutes, + this.isFreeTrial, + this.createdAt, + }); +} + +class ChatRequestStaleData extends ChatRequestData { + final String sessionId; + final StaleReason reason; + const ChatRequestStaleData(this.sessionId, this.reason); } class ChatRequestAcceptingData extends ChatRequestData { @@ -47,6 +68,7 @@ class ChatRequestErrorData extends ChatRequestData { class ChatRequest extends _$ChatRequest { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; + final List> _pendingQueue = []; ApiClient get _apiClient => ref.read(apiClientProvider); @@ -54,7 +76,6 @@ class ChatRequest extends _$ChatRequest { ChatRequestData build() => const ChatRequestIdleData(); Future startListening() async { - // Don't reset state if actively accepting/accepted — would lose navigation if (state is ChatRequestAcceptingData || state is ChatRequestAcceptedData) return; _closeWebSocket(); state = const ChatRequestListeningData(); @@ -63,6 +84,7 @@ class ChatRequest extends _$ChatRequest { void stopListening() { _closeWebSocket(); + _pendingQueue.clear(); state = const ChatRequestIdleData(); } @@ -112,33 +134,65 @@ class ChatRequest extends _$ChatRequest { if (type == WsMessage.chatRequest) { final sessionId = data['session_id'] as String; - state = ChatRequestIncomingData(sessionId); - // Show local notification so mitra is alerted even when app is backgrounded + + // If already showing a request or stale message, queue it + if (state is ChatRequestIncomingData || + state is ChatRequestStaleData || + state is ChatRequestAcceptingData) { + if (!_pendingQueue.any((q) => q['session_id'] == sessionId)) { + _pendingQueue.add(data); + } + } else { + state = ChatRequestIncomingData( + sessionId, + durationMinutes: data['duration_minutes'] as int?, + isFreeTrial: data['is_free_trial'] as bool?, + createdAt: data['created_at'] != null + ? DateTime.tryParse(data['created_at'] as String) + : null, + ); + } + + // Show local notification NotificationService.showLocalNotification( title: 'Permintaan Chat Baru', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.', data: {'type': 'chat_request', 'session_id': sessionId, 'action': 'open_accept'}, ); } else if (type == WsMessage.chatRequestClosed) { - if (state is ChatRequestIncomingData) { - state = const ChatRequestListeningData(); + final closedSessionId = data['session_id'] as String; + final reason = data['reason'] as String?; + + // Remove from queue if queued + _pendingQueue.removeWhere((q) => q['session_id'] == closedSessionId); + + // If currently displayed, transition to stale + if (state is ChatRequestIncomingData && + (state as ChatRequestIncomingData).sessionId == closedSessionId) { + final staleReason = switch (reason) { + 'cancelled_by_customer' => StaleReason.cancelledByCustomer, + 'accepted_by_other' => StaleReason.acceptedByOther, + 'expired' => StaleReason.expired, + _ => StaleReason.expired, + }; + state = ChatRequestStaleData(closedSessionId, staleReason); } } else if (type == 'session_rerouted') { + _pendingQueue.clear(); state = const ChatRequestListeningData(); } else if (type == 'session_assigned') { + _pendingQueue.clear(); state = ChatRequestAcceptedData({'session_id': data['session_id']}); } } - /// Called when user taps a chat_request notification. Sets the incoming state - /// with the given session and validates it's still pending. + /// Called when user taps a chat_request notification. Future setIncomingFromNotification(String sessionId) async { state = ChatRequestIncomingData(sessionId); await validateIncomingRequest(); } - /// Check if the current incoming request is still valid (pending_acceptance). - /// If stale, reset to listening state. + /// Check if the current incoming request is still valid. Future validateIncomingRequest() async { if (state is! ChatRequestIncomingData) return; final sessionId = (state as ChatRequestIncomingData).sessionId; @@ -146,10 +200,39 @@ class ChatRequest extends _$ChatRequest { final response = await _apiClient.get('/api/mitra/chat-requests/$sessionId/status'); final status = response['data']?['status'] as String?; if (status != 'pending_acceptance') { - state = const ChatRequestListeningData(); + state = ChatRequestStaleData(sessionId, StaleReason.expired); } } catch (_) { - // On error, keep current state — don't dismiss valid requests + // On error, keep current state + } + } + + /// Swipe down on active request — ignore without sending reject to backend. + void ignore() { + _advanceQueue(); + } + + /// Acknowledge a stale message (OK button or swipe down). + void acknowledgeStale() { + _advanceQueue(); + } + + /// Show next queued request or return to listening. + void _advanceQueue() { + if (_pendingQueue.isNotEmpty) { + final next = _pendingQueue.removeAt(0); + final sessionId = next['session_id'] as String; + state = ChatRequestIncomingData( + sessionId, + durationMinutes: next['duration_minutes'] as int?, + isFreeTrial: next['is_free_trial'] as bool?, + createdAt: next['created_at'] != null + ? DateTime.tryParse(next['created_at'] as String) + : null, + ); + validateIncomingRequest(); + } else { + state = const ChatRequestListeningData(); } } @@ -157,11 +240,12 @@ class ChatRequest extends _$ChatRequest { state = const ChatRequestAcceptingData(); try { final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept'); + _pendingQueue.clear(); state = ChatRequestAcceptedData(response['data'] as Map); } on DioException catch (e) { final code = e.response?.data?['error']?['code']; if (code == 'REQUEST_UNAVAILABLE') { - state = const ChatRequestListeningData(); + state = ChatRequestStaleData(sessionId, StaleReason.acceptedByOther); } else { state = const ChatRequestErrorData('Gagal menerima. Coba lagi.'); } @@ -172,7 +256,7 @@ class ChatRequest extends _$ChatRequest { try { await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline'); } catch (_) {} - state = const ChatRequestListeningData(); + _advanceQueue(); } void _closeWebSocket() { diff --git a/mitra_app/lib/core/chat/chat_request_notifier.g.dart b/mitra_app/lib/core/chat/chat_request_notifier.g.dart index c331f6a..254be4e 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.g.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.g.dart @@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatRequestHash() => r'b99836c687e861493c432ff5a5901a70f24ab1c7'; +String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3'; /// See also [ChatRequest]. @ProviderFor(ChatRequest) diff --git a/mitra_app/lib/core/chat/widgets/chat_request_overlay.dart b/mitra_app/lib/core/chat/widgets/chat_request_overlay.dart new file mode 100644 index 0000000..014a40c --- /dev/null +++ b/mitra_app/lib/core/chat/widgets/chat_request_overlay.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../chat_request_notifier.dart'; +import '../../../router.dart'; + +class ChatRequestOverlay extends ConsumerStatefulWidget { + final Widget child; + const ChatRequestOverlay({super.key, required this.child}); + + @override + ConsumerState createState() => _ChatRequestOverlayState(); +} + +class _ChatRequestOverlayState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _animController; + late final Animation _slideAnimation; + bool _visible = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _slideAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic)); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + void _show() { + if (!_visible) { + setState(() => _visible = true); + _animController.forward(); + } + } + + void _hide() { + _animController.reverse().then((_) { + if (mounted) setState(() => _visible = false); + }); + } + + void _onSwipeDown(DragEndDetails details) { + if (details.primaryVelocity != null && details.primaryVelocity! > 200) { + final state = ref.read(chatRequestProvider); + if (state is ChatRequestIncomingData) { + ref.read(chatRequestProvider.notifier).ignore(); + } else if (state is ChatRequestStaleData) { + ref.read(chatRequestProvider.notifier).acknowledgeStale(); + } + } + } + + @override + Widget build(BuildContext context) { + ref.listen(chatRequestProvider, (prev, next) { + if (next is ChatRequestIncomingData || next is ChatRequestStaleData) { + _show(); + } else if (next is ChatRequestAcceptedData) { + _hide(); + // Navigate to chat session + final session = next.session; + final sessionId = session['session_id'] as String? ?? session['id'] as String; + final router = ref.read(routerProvider); + router.push('/chat/session/$sessionId', extra: { + 'customerName': session['customer_display_name'] as String? ?? 'Customer', + }); + } else { + _hide(); + } + }); + + return Stack( + children: [ + widget.child, + if (_visible) ...[ + // Semi-transparent dim + Positioned.fill( + child: GestureDetector( + onTap: () {}, // Block taps but don't dismiss + child: FadeTransition( + opacity: _animController, + child: Container(color: Colors.black.withOpacity(0.3)), + ), + ), + ), + // Overlay content + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SlideTransition( + position: _slideAnimation, + child: GestureDetector( + onVerticalDragEnd: _onSwipeDown, + child: _buildContent(), + ), + ), + ), + ], + ], + ); + } + + Widget _buildContent() { + final requestState = ref.watch(chatRequestProvider); + + if (requestState is ChatRequestIncomingData) { + return _buildActiveRequest(requestState); + } + if (requestState is ChatRequestStaleData) { + return _buildStaleRequest(requestState); + } + return const SizedBox.shrink(); + } + + Widget _buildActiveRequest(ChatRequestIncomingData data) { + final durationText = data.isFreeTrial == true + ? 'Free Trial' + : data.durationMinutes != null + ? '${data.durationMinutes} Menit' + : ''; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const Icon(Icons.chat, size: 48, color: Colors.blue), + const SizedBox(height: 12), + const Text( + 'Ada permintaan chat baru!', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (durationText.isNotEmpty) + Text( + 'Durasi: $durationText', + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 8), + const Text( + 'Seorang customer ingin curhat denganmu.', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + ref.read(chatRequestProvider.notifier).decline(data.sessionId); + }, + child: const Text('Tolak'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + ref.read(chatRequestProvider.notifier).accept(data.sessionId); + }, + child: const Text('Terima'), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Geser ke bawah untuk mengabaikan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStaleRequest(ChatRequestStaleData data) { + final message = switch (data.reason) { + StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh customer', + StaleReason.acceptedByOther => 'Permintaan diterima oleh Bestie lain', + StaleReason.expired => 'Permintaan kedaluwarsa', + }; + + final icon = switch (data.reason) { + StaleReason.cancelledByCustomer => Icons.cancel_outlined, + StaleReason.acceptedByOther => Icons.people_outline, + StaleReason.expired => Icons.timer_off_outlined, + }; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + Icon(icon, size: 48, color: Colors.orange), + const SizedBox(height: 12), + Text( + message, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + ref.read(chatRequestProvider.notifier).acknowledgeStale(); + }, + child: const Text('OK'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart b/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart deleted file mode 100644 index 8c821d2..0000000 --- a/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/chat/chat_request_notifier.dart'; - -class IncomingRequestSheet extends ConsumerWidget { - final String sessionId; - const IncomingRequestSheet({super.key, required this.sessionId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final requestState = ref.watch(chatRequestProvider); - - // Request is still active — show accept/decline - if (requestState is ChatRequestIncomingData) { - return _buildActiveRequest(context, ref); - } - - // Request was taken by another mitra or cancelled — show info - return _buildStaleRequest(context); - } - - Widget _buildActiveRequest(BuildContext context, WidgetRef ref) { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.chat, size: 48, color: Colors.blue), - const SizedBox(height: 16), - const Text( - 'Ada permintaan chat baru!', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Seorang customer ingin curhat denganmu.', - style: TextStyle(fontSize: 14, color: Colors.grey), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () { - ref.read(chatRequestProvider.notifier).decline(sessionId); - Navigator.of(context).pop(); - }, - child: const Text('Tolak'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () { - ref.read(chatRequestProvider.notifier).accept(sessionId); - Navigator.of(context).pop(); - }, - child: const Text('Terima'), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildStaleRequest(BuildContext context) { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.info_outline, size: 48, color: Colors.orange), - const SizedBox(height: 16), - const Text( - 'Permintaan tidak tersedia', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Permintaan ini sudah dibatalkan oleh customer atau diterima oleh Bestie lain.', - style: TextStyle(fontSize: 14, color: Colors.grey), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ), - ], - ), - ); - } -} diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index 69a2dcd..72c795e 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -5,54 +5,12 @@ import '../../core/auth/auth_notifier.dart'; import '../../core/status/status_notifier.dart'; import '../../core/chat/chat_request_notifier.dart'; import '../../core/chat/unread_notifier.dart'; -import '../chat/widgets/incoming_request_sheet.dart'; -class HomeScreen extends ConsumerStatefulWidget { +class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override - ConsumerState createState() => _HomeScreenState(); -} - -class _HomeScreenState extends ConsumerState 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) { - final chatState = ref.read(chatRequestProvider); - if (chatState is ChatRequestIncomingData) { - // Validate the request is still pending before showing - ref.read(chatRequestProvider.notifier).validateIncomingRequest().then((_) { - final current = ref.read(chatRequestProvider); - if (current is ChatRequestIncomingData) { - _showIncomingRequest(current.sessionId); - } - }); - } - } - } - - void _showIncomingRequest(String sessionId) { - showModalBottomSheet( - context: context, - isDismissible: false, - builder: (_) => IncomingRequestSheet(sessionId: sessionId), - ); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(mitraAuthProvider); final authData = authState.valueOrNull; final displayName = authData is MitraAuthAuthenticatedData @@ -68,19 +26,6 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } }); - // Listen for incoming chat requests — fresh from WS, show immediately - ref.listen(chatRequestProvider, (prev, next) { - if (next is ChatRequestIncomingData) { - _showIncomingRequest(next.sessionId); - } else if (next is ChatRequestAcceptedData) { - final session = next.session; - final sessionId = session['session_id'] as String? ?? session['id'] as String; - context.push('/chat/session/$sessionId', extra: { - 'customerName': session['customer_display_name'] as String? ?? 'Customer', - }); - } - }); - return Scaffold( appBar: AppBar( title: const Text('Halo Bestie Mitra'), diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index 0d7cc93..ee99489 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -6,6 +6,7 @@ import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; import 'core/status/status_notifier.dart'; import 'core/chat/chat_request_notifier.dart'; +import 'core/chat/widgets/chat_request_overlay.dart'; import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -83,9 +84,11 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId); }; - return MaterialApp.router( - title: 'Halo Bestie Mitra', - routerConfig: router, + return ChatRequestOverlay( + child: MaterialApp.router( + title: 'Halo Bestie Mitra', + routerConfig: router, + ), ); } }