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

@@ -14,6 +14,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -42,6 +43,10 @@ android {
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -32,8 +32,4 @@ class ApiClient {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
Future<Response> getStream(String path) async {
return _dio.get(path, options: Options(responseType: ResponseType.stream));
}
}

View File

@@ -76,7 +76,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);

View File

@@ -5,6 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.dart';
// Events
abstract class ChatEvent extends Equatable {
@@ -125,8 +126,8 @@ class ChatMessage {
required this.id,
required this.senderType,
required this.content,
this.type = 'text',
this.status = 'sent',
this.type = MessageType.text,
this.status = MessageStatus.sent,
required this.createdAt,
});
@@ -164,6 +165,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(ChatConnecting());
try {
// Check session status before connecting
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) {
emit(ChatError('Sesi sudah berakhir.'));
return;
}
final isClosing = sessionStatus == SessionStatus.closing;
// Load existing messages from API
final response = await apiClient.get(
'/api/shared/chat/${event.sessionId}/messages',
@@ -173,8 +187,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
id: m['id'] as String,
senderType: m['sender_type'] as String,
content: m['content'] as String,
type: m['type'] as String? ?? 'text',
status: m['status'] as String? ?? 'sent',
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
)).toList();
@@ -197,12 +211,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// Send auth message
_channel!.sink.add(jsonEncode({
'type': 'auth',
'type': WsMessage.auth,
'token': token,
'session_id': event.sessionId,
}));
emit(ChatConnected(messages: messages));
emit(ChatConnected(
messages: messages,
sessionClosing: isClosing,
));
} catch (e) {
emit(ChatError('Gagal terhubung ke chat.'));
}
@@ -221,7 +238,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage(
id: tempId,
senderType: 'customer',
senderType: UserType.customer,
content: event.content,
status: 'sending',
createdAt: DateTime.now(),
@@ -230,7 +247,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(current.copyWith(messages: [...current.messages, msg]));
_channel!.sink.add(jsonEncode({
'type': 'message',
'type': WsMessage.message,
'content': event.content,
'_temp_id': tempId,
}));
@@ -238,13 +255,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'typing'}));
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'delivered',
'type': WsMessage.delivered,
'message_ids': event.messageIds,
}));
}
@@ -252,7 +269,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'read',
'type': WsMessage.read,
'message_ids': event.messageIds,
}));
}
@@ -264,17 +281,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final type = data['type'] as String?;
switch (type) {
case 'auth_ok':
case WsMessage.authOk:
// Already connected
break;
case 'message':
case WsMessage.message:
final msg = ChatMessage(
id: data['message_id'] as String,
senderType: data['sender_type'] as String,
content: data['content'] as String,
type: data['message_type'] as String? ?? 'text',
status: 'sent',
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
);
emit(current.copyWith(messages: [...current.messages, msg]));
@@ -282,7 +299,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(MarkMessagesDelivered([msg.id]));
break;
case 'message_ack':
case WsMessage.messageAck:
final messageId = data['message_id'] as String;
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
@@ -292,7 +309,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return m;
}).toList();
// Replace temp ID with real ID
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer');
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
if (idx >= 0) {
final old = updatedMessages[idx];
updatedMessages[idx] = ChatMessage(
@@ -307,7 +324,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(current.copyWith(messages: updatedMessages));
break;
case 'message_status':
case WsMessage.messageStatus:
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
@@ -319,7 +336,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(current.copyWith(messages: updatedMessages));
break;
case 'typing':
case WsMessage.typing:
emit(current.copyWith(isOtherTyping: true));
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
@@ -329,36 +346,41 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
});
break;
case 'session_timer':
case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?;
emit(current.copyWith(remainingSeconds: remaining));
break;
case 'session_expired':
case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true));
break;
case 'session_paused':
case WsMessage.sessionPaused:
emit(current.copyWith(sessionPaused: true));
break;
case 'session_resumed':
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
case WsMessage.sessionResumed:
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
break;
case 'session_closing':
case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true));
break;
case 'extension_response':
emit(current.copyWith(extensionResponse: data));
case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false;
emit(current.copyWith(
extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired,
));
break;
case 'session_completed':
case WsMessage.sessionCompleted:
_cleanup();
break;
case 'error':
case WsMessage.error:
// Keep connected but show error
break;
}

View File

@@ -18,6 +18,7 @@ class RequestExtension extends SessionClosureEvent {
}
class DeclineExtension extends SessionClosureEvent {}
class ResetClosure extends SessionClosureEvent {}
class SubmitGoodbye extends SessionClosureEvent {
final String sessionId;
@@ -56,6 +57,7 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
on<RequestExtension>(_onRequestExtension);
on<DeclineExtension>(_onDeclineExtension);
on<ResetClosure>(_onReset);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
@@ -76,6 +78,10 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
emit(ClosureShowGoodbye());
}
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
emit(ClosureInitial());
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
emit(ClosureSubmitting());
try {

View File

@@ -0,0 +1,83 @@
/// User types
class UserType {
static const customer = 'customer';
static const mitra = 'mitra';
UserType._();
}
/// Chat session statuses
class SessionStatus {
static const searching = 'searching';
static const pendingAcceptance = 'pending_acceptance';
static const pendingPayment = 'pending_payment';
static const active = 'active';
static const extending = 'extending';
static const closing = 'closing';
static const completed = 'completed';
static const cancelled = 'cancelled';
static const expired = 'expired';
SessionStatus._();
}
/// Chat message statuses
class MessageStatus {
static const sent = 'sent';
static const delivered = 'delivered';
static const read = 'read';
MessageStatus._();
}
/// Chat message types
class MessageType {
static const text = 'text';
MessageType._();
}
/// Session extension statuses
class ExtensionStatus {
static const pending = 'pending';
static const accepted = 'accepted';
static const rejected = 'rejected';
static const timeout = 'timeout';
ExtensionStatus._();
}
/// WebSocket message types
class WsMessage {
// Auth
static const auth = 'auth';
static const authOk = 'auth_ok';
static const error = 'error';
// Chat
static const message = 'message';
static const messageAck = 'message_ack';
static const messageStatus = 'message_status';
static const typing = 'typing';
// Pairing
static const chatRequest = 'chat_request';
static const chatRequestClosed = 'chat_request_closed';
static const paired = 'paired';
// Session lifecycle
static const sessionTimer = 'session_timer';
static const sessionExpired = 'session_expired';
static const sessionClosing = 'session_closing';
static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed';
// Extension
static const extensionRequest = 'extension_request';
static const extensionResponse = 'extension_response';
// Delivery
static const delivered = 'delivered';
static const read = 'read';
// Early end
static const earlyEnd = 'early_end';
WsMessage._();
}

View File

@@ -0,0 +1,95 @@
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:go_router/go_router.dart';
/// Handles FCM foreground/background notifications and local notification display.
class NotificationService {
static final _localNotifications = FlutterLocalNotificationsPlugin();
static GoRouter? _router;
static const _channel = AndroidNotificationChannel(
'chat_messages',
'Chat Messages',
description: 'Notifications for incoming chat messages',
importance: Importance.high,
);
static Future<void> initialize(GoRouter router) async {
_router = router;
// Create Android notification channel
await _localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_channel);
// Initialize local notifications
await _localNotifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: _onNotificationTap,
);
// FCM foreground messages → show local notification
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
// FCM notification tap (app was in background)
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
// Check if app was opened from a terminated state via notification
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_navigateFromMessage(initialMessage.data);
}
}
static void _onForegroundMessage(RemoteMessage message) {
final notification = message.notification;
if (notification == null) return;
_localNotifications.show(
id: notification.hashCode,
title: notification.title,
body: notification.body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: jsonEncode(message.data),
);
}
static void _onNotificationTap(NotificationResponse response) {
if (response.payload == null) return;
try {
final data = jsonDecode(response.payload!) as Map<String, dynamic>;
_navigateFromMessage(data);
} catch (_) {}
}
static void _onMessageOpenedApp(RemoteMessage message) {
_navigateFromMessage(message.data);
}
static void _navigateFromMessage(Map<String, dynamic> data) {
final sessionId = data['session_id'] as String?;
if (sessionId == null || _router == null) return;
final type = data['type'] as String?;
if (type == 'chat_message' || type == 'chat_request') {
_router!.push('/chat/session/$sessionId');
}
}
}

View File

@@ -2,8 +2,11 @@ import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.dart';
// Events
abstract class PairingEvent extends Equatable {
@@ -32,6 +35,7 @@ class _PairingStatusUpdate extends PairingEvent {
}
class _PairingTimeout extends PairingEvent {}
class _ConnectionError extends PairingEvent {}
// States
abstract class PairingState extends Equatable {
@@ -77,7 +81,8 @@ class PairingError extends PairingState {
class PairingBloc extends Bloc<PairingEvent, PairingState> {
final ApiClient apiClient;
Timer? _timeoutTimer;
StreamSubscription? _sseSubscription;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
@@ -85,6 +90,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
on<_ConnectionError>(_onConnectionError);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
@@ -107,6 +113,9 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
emit(PairingInitial());
}
try {
// Connect to WebSocket first to listen for pairing status
await _connectWebSocket();
final response = await apiClient.post('/api/client/chat/request', data: body);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
@@ -116,9 +125,8 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
_timeoutTimer = Timer(const Duration(seconds: 60), () {
add(_PairingTimeout());
});
_listenToSSE(sessionId);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
emit(PairingNoBestie());
@@ -132,26 +140,45 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
}
}
void _listenToSSE(String sessionId) {
apiClient.getStream('/api/client/chat/request/$sessionId/status').then((response) {
final stream = response.data.stream as Stream<List<int>>;
_sseSubscription = stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.where((line) => line.startsWith('data: '))
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
.listen(
(data) => add(_PairingStatusUpdate(data)),
onError: (_) {},
);
}).catchError((_) {});
Future<void> _connectWebSocket() async {
_closeWebSocket();
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
add(_PairingStatusUpdate(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
// Authenticate without session_id — just for receiving pairing status
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
}
Future<void> _onConnectionError(_ConnectionError event, Emitter<PairingState> emit) async {
// WebSocket disconnected during pairing — stay in current state,
// FCM will still deliver notifications
}
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == 'paired') {
if (type == WsMessage.paired) {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
@@ -160,7 +187,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
// Brief delay then transition to active
await Future.delayed(const Duration(seconds: 2));
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
} else if (type == 'expired') {
} else if (type == SessionStatus.expired) {
_cleanup();
emit(PairingNoBestie());
}
@@ -182,11 +209,17 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
emit(PairingNoBestie());
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_sseSubscription?.cancel();
_sseSubscription = null;
_closeWebSocket();
}
@override

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/chat_bloc.dart';
import '../../../core/chat/session_closure_bloc.dart';
import '../../../core/constants.dart';
import '../widgets/pricing_bottom_sheet.dart';
class ChatScreen extends StatefulWidget {
@@ -21,8 +22,15 @@ class _ChatScreenState extends State<ChatScreen> {
final _scrollController = ScrollController();
Timer? _typingThrottle;
@override
void initState() {
super.initState();
context.read<ChatBloc>().add(ConnectChat(widget.sessionId));
}
@override
void dispose() {
context.read<ChatBloc>().add(DisconnectChat());
_messageController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
@@ -64,19 +72,31 @@ class _ChatScreenState extends State<ChatScreen> {
if (prev is ChatConnected && curr is ChatConnected) {
return prev.sessionExpired != curr.sessionExpired ||
prev.sessionClosing != curr.sessionClosing ||
prev.sessionPaused != curr.sessionPaused ||
prev.messages.length != curr.messages.length;
}
return true;
},
listener: (context, state) {
if (state is ChatConnected) {
if (state.sessionClosing) {
context.read<SessionClosureBloc>().add(DeclineExtension());
// Only trigger goodbye if closing AND not expired (expired shows extend dialog first)
if (state.sessionClosing && !state.sessionExpired) {
final closureState = context.read<SessionClosureBloc>().state;
if (closureState is ClosureInitial) {
context.read<SessionClosureBloc>().add(DeclineExtension());
}
}
// Extension accepted — reset closure bloc to go back to chat
if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) {
final closureState = context.read<SessionClosureBloc>().state;
if (closureState is! ClosureInitial) {
context.read<SessionClosureBloc>().add(ResetClosure());
}
}
_scrollToBottom();
// Auto-mark received messages as read
final unread = state.messages
.where((m) => m.senderType == 'mitra' && m.status != 'read')
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
.map((m) => m.id)
.toList();
if (unread.isNotEmpty) {
@@ -138,17 +158,17 @@ class _ChatScreenState extends State<ChatScreen> {
}
Widget _buildChatBody(BuildContext context, ChatConnected state) {
// Show session expired dialog
if (state.sessionExpired) {
return _buildExpiredView(context);
}
// Show goodbye input
// Show goodbye input (takes priority — user already decided to close)
final closureState = context.watch<SessionClosureBloc>().state;
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
return _buildGoodbyeView(context, closureState);
}
// Show session expired dialog (extend or close?)
if (state.sessionExpired) {
return _buildExpiredView(context);
}
if (state.sessionPaused) {
return _buildPausedView();
}
@@ -162,7 +182,7 @@ class _ChatScreenState extends State<ChatScreen> {
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == 'customer';
final isMe = msg.senderType == UserType.customer;
return _buildMessageBubble(msg, isMe);
},
),
@@ -219,11 +239,11 @@ class _ChatScreenState extends State<ChatScreen> {
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();
@@ -264,25 +284,48 @@ class _ChatScreenState extends State<ChatScreen> {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.timer_off, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Perpanjang Sesi'),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
child: const Text('Tidak, akhiri sesi'),
),
],
child: TweenAnimationBuilder<int>(
tween: IntTween(begin: 300, end: 0),
duration: const Duration(seconds: 300),
builder: (context, remaining, _) {
if (remaining <= 0) {
// Auto-decline when countdown reaches 0
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SessionClosureBloc>().add(DeclineExtension());
});
}
final minutes = remaining ~/ 60;
final seconds = remaining % 60;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.timer_off, size: 64, color: Colors.orange),
const SizedBox(height: 16),
const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center),
const SizedBox(height: 12),
Text(
'$minutes:${seconds.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: remaining < 60 ? Colors.red : Colors.orange,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId),
child: const Text('Perpanjang Sesi'),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
child: const Text('Tidak, akhiri sesi'),
),
],
);
},
),
),
);
@@ -290,13 +333,12 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) {
final controller = TextEditingController();
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
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)),
const SizedBox(height: 8),
@@ -328,7 +370,6 @@ class _ChatScreenState extends State<ChatScreen> {
),
],
),
),
);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
import '../../../core/constants.dart';
class ChatTranscriptScreen extends StatefulWidget {
final String sessionId;
@@ -47,7 +48,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
padding: const EdgeInsets.all(16),
children: [
..._messages.map((m) {
final isMe = m['sender_type'] == 'customer';
final isMe = m['sender_type'] == UserType.customer;
final time = DateTime.parse(m['created_at'] as String).toLocal();
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
@@ -79,7 +80,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
const SizedBox(height: 8),
..._closures.map((c) => Card(
child: ListTile(
title: Text(c['user_type'] == 'customer' ? 'Kamu' : 'Bestie'),
title: Text(c['user_type'] == UserType.customer ? 'Kamu' : 'Bestie'),
subtitle: Text(c['message'] as String),
),
)),

View File

@@ -2,18 +2,45 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
import '../../../core/chat/chat_opening_bloc.dart';
import '../../../core/chat/session_closure_bloc.dart';
import '../../../core/pairing/pairing_bloc.dart';
class PricingBottomSheet extends StatelessWidget {
const PricingBottomSheet({super.key});
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId;
const PricingBottomSheet({super.key, this.extensionSessionId});
/// Show for new pairing (from home screen)
static Future<void> show(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: const PricingBottomSheet(),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<PairingBloc>()),
],
child: const PricingBottomSheet(),
),
),
);
}
/// Show for session extension (from chat screen)
static Future<void> showForExtension(BuildContext context, {required String sessionId}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<SessionClosureBloc>()),
],
child: PricingBottomSheet(extensionSessionId: sessionId),
),
),
);
}
@@ -30,6 +57,8 @@ class PricingBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isExtension = extensionSessionId != null;
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
builder: (context, state) {
if (state is PricingLoading || state is PricingInitial) {
@@ -58,13 +87,13 @@ class PricingBottomSheet extends StatelessWidget {
child: ListView(
controller: scrollController,
children: [
const Text(
'Pilih Durasi Curhat',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (state.freeTrialEligible) ...[
if (!isExtension && state.freeTrialEligible) ...[
Card(
color: Colors.green.shade50,
child: ListTile(
@@ -89,11 +118,20 @@ class PricingBottomSheet extends StatelessWidget {
),
onTap: () {
Navigator.of(context).pop();
_startPairing(
context,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
if (isExtension) {
_requestExtension(
context,
sessionId: extensionSessionId!,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
} else {
_startPairing(
context,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
},
),
)),
@@ -116,4 +154,12 @@ class PricingBottomSheet extends StatelessWidget {
isFreeTrial: isFreeTrial,
));
}
void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) {
context.read<SessionClosureBloc>().add(RequestExtension(
sessionId: sessionId,
durationMinutes: durationMinutes,
price: price,
));
}
}

View File

@@ -2,12 +2,64 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart';
import '../../core/api/api_client.dart';
import '../../core/pairing/pairing_bloc.dart';
import '../chat/widgets/pricing_bottom_sheet.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
Map<String, dynamic>? _activeSession;
bool _loadingSession = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkActiveSession();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkActiveSession();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Re-check when navigating back to this screen
_checkActiveSession();
}
Future<void> _checkActiveSession() async {
try {
final apiClient = context.read<ApiClient>();
final response = await apiClient.get('/api/client/chat/session/active');
final data = response['data'];
if (mounted) {
setState(() {
_activeSession = data is Map<String, dynamic> ? data : null;
_loadingSession = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingSession = false);
}
}
@override
Widget build(BuildContext context) {
return BlocListener<PairingBloc, PairingState>(
@@ -51,14 +103,28 @@ class HomeScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
const SizedBox(height: 32),
if (_loadingSession)
const CircularProgressIndicator()
else if (_activeSession != null)
_ActiveSessionCard(
session: _activeSession!,
onTap: () {
final sessionId = _activeSession!['id'] as String;
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
context.push('/chat/session/$sessionId', extra: mitraName);
},
)
else ...[
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
],
),
),
@@ -69,3 +135,52 @@ class HomeScreen extends StatelessWidget {
);
}
}
class _ActiveSessionCard extends StatelessWidget {
final Map<String, dynamic> session;
final VoidCallback onTap;
const _ActiveSessionCard({required this.session, required this.onTap});
@override
Widget build(BuildContext context) {
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
return Card(
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
const CircleAvatar(
backgroundColor: Colors.green,
child: Icon(Icons.chat, color: Colors.white),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sesi Aktif',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Sedang curhat dengan $mitraName',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite, size: 80, color: Colors.blue),
SizedBox(height: 24),
Text(
'Halo Bestie',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
SizedBox(height: 32),
CircularProgressIndicator(),
],
),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'core/auth/auth_bloc.dart';
import 'core/chat/chat_bloc.dart';
import 'core/chat/session_closure_bloc.dart';
import 'core/pairing/pairing_bloc.dart';
import 'core/notifications/notification_service.dart';
import 'firebase_options.dart';
import 'router.dart';
@@ -39,6 +40,7 @@ class _AppState extends State<App> {
super.initState();
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
_router = buildRouter(_authBloc);
NotificationService.initialize(_router);
_registerFcmToken();
}

View File

@@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart';
@@ -32,24 +33,27 @@ class _BlocRefreshNotifier extends ChangeNotifier {
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/welcome',
initialLocation: '/splash',
refreshListenable: _BlocRefreshNotifier(authBloc),
redirect: (context, state) {
final authState = authBloc.state;
final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome';
// Don't redirect while loading — stay on current screen
if (authState is AuthLoading) return null;
// Show splash while loading
if (authState is AuthLoading) return isSplash ? null : '/splash';
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
return isAuthRoute ? '/home' : null;
return (isSplash || isAuthRoute) ? '/home' : null;
}
if (authState is AuthForceRegister) return '/auth/force-register';
if (!isAuthRoute) return '/welcome';
if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome';
return null;
},
routes: [
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
@@ -66,10 +70,13 @@ GoRouter buildRouter(AuthBloc authBloc) {
}),
GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
final extra = state.extra;
final mitraName = extra is String
? extra
: (extra is Map<String, dynamic> ? extra['mitraName'] as String? : null);
return ChatScreen(
sessionId: state.pathParameters['sessionId']!,
mitraName: extra?['mitraName'] as String? ?? 'Bestie',
mitraName: mitraName ?? 'Bestie',
);
}),
GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()),

View File

@@ -8,6 +8,7 @@ import Foundation
import firebase_auth
import firebase_core
import firebase_messaging
import flutter_local_notifications
import google_sign_in_ios
import shared_preferences_foundation
import sign_in_with_apple
@@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))

View File

@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.35"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -65,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
@@ -206,6 +222,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -400,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -557,6 +613,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data:
dependency: transitive
description:
@@ -605,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1"

View File

@@ -33,6 +33,7 @@ dependencies:
# Navigation
go_router: ^13.2.1
flutter_local_notifications: ^21.0.0
dev_dependencies:
flutter_test:

View File

@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)