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

@@ -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/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<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) {
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<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(
appBar: AppBar(
title: const Text('Halo Bestie Mitra'),