Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle

- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 00:17:25 +08:00
parent b4efcf14c2
commit b0502ac92b
58 changed files with 2148 additions and 709 deletions

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/mitra_chat_bloc.dart';
import '../../../core/chat/extension_bloc.dart';
import '../../../core/constants.dart';
class MitraChatScreen extends StatefulWidget {
final String sessionId;
@@ -20,8 +21,15 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
final _scrollController = ScrollController();
Timer? _typingThrottle;
@override
void initState() {
super.initState();
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId));
}
@override
void dispose() {
context.read<MitraChatBloc>().add(DisconnectChat());
_messageController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
@@ -63,7 +71,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
if (state is ChatConnected) {
_scrollToBottom();
final unread = state.messages
.where((m) => m.senderType == 'customer' && m.status != 'read')
.where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read)
.map((m) => m.id)
.toList();
if (unread.isNotEmpty) {
@@ -147,7 +155,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == 'mitra';
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
@@ -204,11 +212,11 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
switch (status) {
case 'sending':
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
case 'sent':
case MessageStatus.sent:
return const Icon(Icons.check, size: 14, color: Colors.grey);
case 'delivered':
case MessageStatus.delivered:
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
case 'read':
case MessageStatus.read:
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
default:
return const SizedBox.shrink();
@@ -249,56 +257,64 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
final duration = request['duration_minutes'] as int?;
final extensionId = request['extension_id'] as String?;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.timer, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
const SizedBox(height: 24),
Row(
return BlocBuilder<ExtensionBloc, ExtensionState>(
builder: (context, extState) {
final isResponding = extState is ExtensionResponding;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId!,
accepted: true,
)),
child: const Text('Terima', style: TextStyle(color: Colors.white)),
),
const SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId!,
accepted: false,
)),
child: const Text('Tolak', style: TextStyle(color: Colors.white)),
),
const Icon(Icons.timer, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
const SizedBox(height: 24),
if (isResponding)
const CircularProgressIndicator()
else
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId,
accepted: true,
)),
child: const Text('Terima', style: TextStyle(color: Colors.white)),
),
const SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId,
accepted: false,
)),
child: const Text('Tolak', style: TextStyle(color: Colors.white)),
),
],
),
],
),
],
),
),
),
);
},
);
}
Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) {
final controller = TextEditingController();
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
return SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
children: [
const SizedBox(height: 48),
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
const SizedBox(height: 16),
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
@@ -331,7 +347,6 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
),
],
),
),
);
}
}