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

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