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<Map>), 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 22:16:30 +08:00
parent 4158fb9432
commit b9c4841eb1
7 changed files with 380 additions and 175 deletions

View File

@@ -101,6 +101,8 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
type: WsMessage.CHAT_REQUEST, type: WsMessage.CHAT_REQUEST,
session_id: session.id, session_id: session.id,
created_at: session.created_at, 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, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, 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 WHERE session_id = ${sessionId} AND response IS NULL
` `
// Notify mitras to dismiss // Notify mitras to dismiss (customer cancelled)
const notifications = await sql` const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} 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, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, session_id: sessionId,
reason: 'cancelled_by_customer',
}) })
} }
@@ -283,7 +287,7 @@ export const expirePairingRequest = async (sessionId) => {
session_id: sessionId, session_id: sessionId,
}) })
// Notify mitras to dismiss // Notify mitras to dismiss (request expired)
const notifications = await sql` const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} 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, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, session_id: sessionId,
reason: 'expired',
}) })
} }

View File

@@ -11,6 +11,13 @@ import '../notifications/notification_service.dart';
part 'chat_request_notifier.g.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 // States
sealed class ChatRequestData { sealed class ChatRequestData {
const ChatRequestData(); const ChatRequestData();
@@ -26,7 +33,21 @@ class ChatRequestListeningData extends ChatRequestData {
class ChatRequestIncomingData extends ChatRequestData { class ChatRequestIncomingData extends ChatRequestData {
final String sessionId; 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 { class ChatRequestAcceptingData extends ChatRequestData {
@@ -47,6 +68,7 @@ class ChatRequestErrorData extends ChatRequestData {
class ChatRequest extends _$ChatRequest { class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _wsSubscription; StreamSubscription? _wsSubscription;
final List<Map<String, dynamic>> _pendingQueue = [];
ApiClient get _apiClient => ref.read(apiClientProvider); ApiClient get _apiClient => ref.read(apiClientProvider);
@@ -54,7 +76,6 @@ class ChatRequest extends _$ChatRequest {
ChatRequestData build() => const ChatRequestIdleData(); ChatRequestData build() => const ChatRequestIdleData();
Future<void> startListening() async { Future<void> startListening() async {
// Don't reset state if actively accepting/accepted — would lose navigation
if (state is ChatRequestAcceptingData || state is ChatRequestAcceptedData) return; if (state is ChatRequestAcceptingData || state is ChatRequestAcceptedData) return;
_closeWebSocket(); _closeWebSocket();
state = const ChatRequestListeningData(); state = const ChatRequestListeningData();
@@ -63,6 +84,7 @@ class ChatRequest extends _$ChatRequest {
void stopListening() { void stopListening() {
_closeWebSocket(); _closeWebSocket();
_pendingQueue.clear();
state = const ChatRequestIdleData(); state = const ChatRequestIdleData();
} }
@@ -112,33 +134,65 @@ class ChatRequest extends _$ChatRequest {
if (type == WsMessage.chatRequest) { if (type == WsMessage.chatRequest) {
final sessionId = data['session_id'] as String; 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( NotificationService.showLocalNotification(
title: 'Permintaan Chat Baru', title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: {'type': 'chat_request', 'session_id': sessionId, 'action': 'open_accept'}, data: {'type': 'chat_request', 'session_id': sessionId, 'action': 'open_accept'},
); );
} else if (type == WsMessage.chatRequestClosed) { } else if (type == WsMessage.chatRequestClosed) {
if (state is ChatRequestIncomingData) { final closedSessionId = data['session_id'] as String;
state = const ChatRequestListeningData(); 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') { } else if (type == 'session_rerouted') {
_pendingQueue.clear();
state = const ChatRequestListeningData(); state = const ChatRequestListeningData();
} else if (type == 'session_assigned') { } else if (type == 'session_assigned') {
_pendingQueue.clear();
state = ChatRequestAcceptedData({'session_id': data['session_id']}); state = ChatRequestAcceptedData({'session_id': data['session_id']});
} }
} }
/// Called when user taps a chat_request notification. Sets the incoming state /// Called when user taps a chat_request notification.
/// with the given session and validates it's still pending.
Future<void> setIncomingFromNotification(String sessionId) async { Future<void> setIncomingFromNotification(String sessionId) async {
state = ChatRequestIncomingData(sessionId); state = ChatRequestIncomingData(sessionId);
await validateIncomingRequest(); await validateIncomingRequest();
} }
/// Check if the current incoming request is still valid (pending_acceptance). /// Check if the current incoming request is still valid.
/// If stale, reset to listening state.
Future<void> validateIncomingRequest() async { Future<void> validateIncomingRequest() async {
if (state is! ChatRequestIncomingData) return; if (state is! ChatRequestIncomingData) return;
final sessionId = (state as ChatRequestIncomingData).sessionId; 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 response = await _apiClient.get('/api/mitra/chat-requests/$sessionId/status');
final status = response['data']?['status'] as String?; final status = response['data']?['status'] as String?;
if (status != 'pending_acceptance') { if (status != 'pending_acceptance') {
state = const ChatRequestListeningData(); state = ChatRequestStaleData(sessionId, StaleReason.expired);
} }
} catch (_) { } 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(); state = const ChatRequestAcceptingData();
try { try {
final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept'); final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept');
_pendingQueue.clear();
state = ChatRequestAcceptedData(response['data'] as Map<String, dynamic>); state = ChatRequestAcceptedData(response['data'] as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
final code = e.response?.data?['error']?['code']; final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') { if (code == 'REQUEST_UNAVAILABLE') {
state = const ChatRequestListeningData(); state = ChatRequestStaleData(sessionId, StaleReason.acceptedByOther);
} else { } else {
state = const ChatRequestErrorData('Gagal menerima. Coba lagi.'); state = const ChatRequestErrorData('Gagal menerima. Coba lagi.');
} }
@@ -172,7 +256,7 @@ class ChatRequest extends _$ChatRequest {
try { try {
await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline'); await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline');
} catch (_) {} } catch (_) {}
state = const ChatRequestListeningData(); _advanceQueue();
} }
void _closeWebSocket() { void _closeWebSocket() {

View File

@@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatRequestHash() => r'b99836c687e861493c432ff5a5901a70f24ab1c7'; String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3';
/// See also [ChatRequest]. /// See also [ChatRequest].
@ProviderFor(ChatRequest) @ProviderFor(ChatRequest)

View File

@@ -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<ChatRequestOverlay> createState() => _ChatRequestOverlayState();
}
class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<Offset> _slideAnimation;
bool _visible = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideAnimation = Tween<Offset>(
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'),
),
),
],
),
),
),
);
}
}

View File

@@ -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'),
),
),
],
),
);
}
}

View File

@@ -5,54 +5,12 @@ import '../../core/auth/auth_notifier.dart';
import '../../core/status/status_notifier.dart'; import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_notifier.dart'; import '../../core/chat/chat_request_notifier.dart';
import '../../core/chat/unread_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}); const HomeScreen({super.key});
@override @override
ConsumerState<HomeScreen> createState() => _HomeScreenState(); Widget build(BuildContext context, WidgetRef ref) {
}
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) {
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) {
final authState = ref.watch(mitraAuthProvider); final authState = ref.watch(mitraAuthProvider);
final authData = authState.valueOrNull; final authData = authState.valueOrNull;
final displayName = authData is MitraAuthAuthenticatedData final displayName = authData is MitraAuthAuthenticatedData
@@ -68,19 +26,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Halo Bestie Mitra'), title: const Text('Halo Bestie Mitra'),

View File

@@ -6,6 +6,7 @@ import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'core/status/status_notifier.dart'; import 'core/status/status_notifier.dart';
import 'core/chat/chat_request_notifier.dart'; import 'core/chat/chat_request_notifier.dart';
import 'core/chat/widgets/chat_request_overlay.dart';
import 'core/notifications/notification_service.dart'; import 'core/notifications/notification_service.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -83,9 +84,11 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId); ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
}; };
return MaterialApp.router( return ChatRequestOverlay(
child: MaterialApp.router(
title: 'Halo Bestie Mitra', title: 'Halo Bestie Mitra',
routerConfig: router, routerConfig: router,
),
); );
} }
} }