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:
@@ -11,6 +11,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -39,6 +40,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -32,8 +32,4 @@ class ApiClient {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Response> getStream(String path) async {
|
||||
return _dio.get(path, options: Options(responseType: ResponseType.stream));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final _auth = FirebaseAuth.instance;
|
||||
ConfirmationResult? _webConfirmationResult;
|
||||
|
||||
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
|
||||
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
|
||||
on<AppStarted>(_onAppStarted);
|
||||
on<PhoneOtpRequested>(_onPhoneOtpRequested);
|
||||
on<OtpVerified>(_onOtpVerified);
|
||||
|
||||
@@ -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 ChatRequestEvent extends Equatable {
|
||||
@@ -21,6 +24,8 @@ class _RequestReceived extends ChatRequestEvent {
|
||||
List<Object?> get props => [data];
|
||||
}
|
||||
|
||||
class _ConnectionError extends ChatRequestEvent {}
|
||||
|
||||
class AcceptRequest extends ChatRequestEvent {
|
||||
final String sessionId;
|
||||
AcceptRequest(this.sessionId);
|
||||
@@ -70,49 +75,76 @@ class ChatRequestError extends ChatRequestState {
|
||||
// Bloc
|
||||
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
final ApiClient apiClient;
|
||||
StreamSubscription? _sseSubscription;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
|
||||
on<StartListening>(_onStartListening);
|
||||
on<StopListening>(_onStopListening);
|
||||
on<_RequestReceived>(_onRequestReceived);
|
||||
on<_ConnectionError>(_onConnectionError);
|
||||
on<AcceptRequest>(_onAcceptRequest);
|
||||
on<DeclineRequest>(_onDeclineRequest);
|
||||
}
|
||||
|
||||
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
emit(ChatRequestListening());
|
||||
_listenToSSE();
|
||||
await _connectWebSocket();
|
||||
}
|
||||
|
||||
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
emit(ChatRequestIdle());
|
||||
}
|
||||
|
||||
void _listenToSSE() {
|
||||
apiClient.getStream('/api/mitra/chat-requests/incoming').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(_RequestReceived(data)),
|
||||
onError: (_) {},
|
||||
);
|
||||
}).catchError((_) {});
|
||||
Future<void> _connectWebSocket() async {
|
||||
try {
|
||||
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; // Auth confirmed, no action needed
|
||||
add(_RequestReceived(data));
|
||||
},
|
||||
onError: (_) => add(_ConnectionError()),
|
||||
onDone: () => add(_ConnectionError()),
|
||||
);
|
||||
|
||||
// Authenticate without session_id — just for receiving notifications
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
}));
|
||||
} catch (_) {
|
||||
add(_ConnectionError());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectionError(_ConnectionError event, Emitter<ChatRequestState> emit) async {
|
||||
_closeWebSocket();
|
||||
// Stay in listening state — FCM will still deliver notifications
|
||||
if (state is! ChatRequestIdle) {
|
||||
emit(ChatRequestListening());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
|
||||
final data = event.data;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == 'chat_request') {
|
||||
if (type == WsMessage.chatRequest) {
|
||||
emit(ChatRequestIncoming(data['session_id'] as String));
|
||||
} else if (type == 'chat_request_closed') {
|
||||
} else if (type == WsMessage.chatRequestClosed) {
|
||||
// Request was taken by another mitra or cancelled
|
||||
if (state is ChatRequestIncoming) {
|
||||
emit(ChatRequestListening());
|
||||
@@ -148,14 +180,16 @@ class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
emit(ChatRequestListening());
|
||||
}
|
||||
|
||||
void _stopSSE() {
|
||||
_sseSubscription?.cancel();
|
||||
_sseSubscription = null;
|
||||
void _closeWebSocket() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopSSE();
|
||||
_closeWebSocket();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
@@ -65,8 +66,14 @@ class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
|
||||
} else {
|
||||
emit(ExtensionIdle());
|
||||
}
|
||||
} catch (e) {
|
||||
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'EXTENSION_RESOLVED') {
|
||||
// Extension already timed out or resolved — move to goodbye
|
||||
emit(ExtensionShowGoodbye());
|
||||
} else {
|
||||
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 MitraChatEvent extends Equatable {
|
||||
@@ -86,6 +87,7 @@ class ChatConnected extends MitraChatState {
|
||||
bool? sessionExpired,
|
||||
bool? sessionClosing,
|
||||
Map<String, dynamic>? extensionRequest,
|
||||
bool clearExtensionRequest = false,
|
||||
}) {
|
||||
return ChatConnected(
|
||||
messages: messages ?? this.messages,
|
||||
@@ -93,7 +95,7 @@ class ChatConnected extends MitraChatState {
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
extensionRequest: extensionRequest ?? this.extensionRequest,
|
||||
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,8 +123,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,
|
||||
});
|
||||
|
||||
@@ -160,14 +162,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
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;
|
||||
|
||||
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
|
||||
final messagesData = response['data'] as List<dynamic>;
|
||||
final messages = messagesData.map((m) => ChatMessage(
|
||||
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();
|
||||
|
||||
@@ -188,12 +203,15 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
);
|
||||
|
||||
_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.'));
|
||||
}
|
||||
@@ -211,7 +229,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
id: tempId,
|
||||
senderType: 'mitra',
|
||||
senderType: UserType.mitra,
|
||||
content: event.content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -220,7 +238,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'message',
|
||||
'type': WsMessage.message,
|
||||
'content': event.content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
@@ -228,17 +246,17 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds}));
|
||||
}
|
||||
|
||||
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'read', 'message_ids': event.messageIds}));
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds}));
|
||||
}
|
||||
|
||||
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
||||
@@ -248,30 +266,30 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case 'auth_ok':
|
||||
case WsMessage.authOk:
|
||||
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]));
|
||||
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) {
|
||||
if (m.status == 'sending') return m.copyWith(status: status);
|
||||
return m;
|
||||
}).toList();
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'mitra');
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
updatedMessages[idx] = ChatMessage(
|
||||
@@ -286,7 +304,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
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) {
|
||||
@@ -296,7 +314,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
case WsMessage.typing:
|
||||
emit(current.copyWith(isOtherTyping: true));
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||
@@ -306,27 +324,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'session_timer':
|
||||
case WsMessage.sessionTimer:
|
||||
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
||||
break;
|
||||
|
||||
case 'session_expired':
|
||||
case WsMessage.sessionExpired:
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
break;
|
||||
|
||||
case 'extension_request':
|
||||
case WsMessage.extensionRequest:
|
||||
emit(current.copyWith(extensionRequest: data));
|
||||
break;
|
||||
|
||||
case 'session_resumed':
|
||||
emit(current.copyWith(sessionExpired: false, extensionRequest: null));
|
||||
case WsMessage.sessionResumed:
|
||||
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
|
||||
break;
|
||||
|
||||
case 'session_closing':
|
||||
emit(current.copyWith(sessionClosing: true));
|
||||
case WsMessage.sessionClosing:
|
||||
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
|
||||
break;
|
||||
|
||||
case 'session_completed':
|
||||
case WsMessage.sessionCompleted:
|
||||
_cleanup();
|
||||
break;
|
||||
}
|
||||
|
||||
83
mitra_app/lib/core/constants.dart
Normal file
83
mitra_app/lib/core/constants.dart
Normal 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._();
|
||||
}
|
||||
95
mitra_app/lib/core/notifications/notification_service.dart
Normal file
95
mitra_app/lib/core/notifications/notification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
|
||||
class ActiveSessionsScreen extends StatefulWidget {
|
||||
@@ -72,14 +73,19 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = _sessions[index];
|
||||
final customerName = session['customer_display_name'] as String? ?? 'Customer';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(session['customer_display_name'] as String? ?? 'Customer'),
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(customerName),
|
||||
subtitle: Text('Status: ${session['status']}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _endSession(session['id'] as String),
|
||||
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/chat/session/${session['id']}',
|
||||
extra: {'customerName': customerName},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 MitraChatTranscriptScreen extends StatefulWidget {
|
||||
final String sessionId;
|
||||
@@ -47,7 +48,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
..._messages.map((m) {
|
||||
final isMe = m['sender_type'] == 'mitra';
|
||||
final isMe = m['sender_type'] == UserType.mitra;
|
||||
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
@@ -79,7 +80,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
||||
const SizedBox(height: 8),
|
||||
..._closures.map((c) => Card(
|
||||
child: ListTile(
|
||||
title: Text(c['user_type'] == 'mitra' ? 'Kamu' : 'Customer'),
|
||||
title: Text(c['user_type'] == UserType.mitra ? 'Kamu' : 'Customer'),
|
||||
subtitle: Text(c['message'] as String),
|
||||
),
|
||||
)),
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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/status/status_bloc.dart';
|
||||
import '../../core/chat/chat_request_bloc.dart';
|
||||
@@ -65,9 +66,11 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
if (state is ChatRequestIncoming) {
|
||||
_showIncomingRequest(state.sessionId);
|
||||
} else if (state is ChatRequestAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sesi baru diterima!')),
|
||||
);
|
||||
final session = state.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',
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -177,7 +180,7 @@ class _ActiveSessionsButton extends StatelessWidget {
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: const Text('Sesi Aktif'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).pushNamed('/sessions'),
|
||||
onTap: () => context.push('/sessions'),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
@@ -185,7 +188,7 @@ class _ActiveSessionsButton extends StatelessWidget {
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Riwayat Chat'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).pushNamed('/chat/history'),
|
||||
onTap: () => context.push('/chat/history'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
26
mitra_app/lib/features/splash/splash_screen.dart
Normal file
26
mitra_app/lib/features/splash/splash_screen.dart
Normal 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 Mitra',
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 32),
|
||||
CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'core/status/status_bloc.dart';
|
||||
import 'core/chat/chat_request_bloc.dart';
|
||||
import 'core/chat/mitra_chat_bloc.dart';
|
||||
import 'core/chat/extension_bloc.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
|
||||
@@ -43,6 +44,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_apiClient = ApiClient();
|
||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||
_router = buildRouter(_authBloc);
|
||||
NotificationService.initialize(_router);
|
||||
_statusBloc = StatusBloc(apiClient: _apiClient);
|
||||
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
||||
_registerFcmToken();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'features/splash/splash_screen.dart';
|
||||
import 'features/auth/screens/login_screen.dart';
|
||||
import 'features/auth/screens/otp_screen.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
@@ -26,19 +27,26 @@ class _BlocRefreshNotifier extends ChangeNotifier {
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
initialLocation: '/splash',
|
||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final isSplash = state.matchedLocation == '/splash';
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||
state.matchedLocation.startsWith('/otp');
|
||||
|
||||
if (authState is AuthLoading) return null;
|
||||
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
|
||||
if (!isAuthRoute) return '/login';
|
||||
// Show splash while loading
|
||||
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||
|
||||
if (authState is AuthAuthenticated) {
|
||||
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||
}
|
||||
if (!isAuthRoute && !isSplash) return '/login';
|
||||
if (isSplash) return '/login';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
|
||||
@@ -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:
|
||||
@@ -97,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -190,6 +214,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
|
||||
@@ -208,6 +264,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.5"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -304,6 +368,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -373,6 +445,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:
|
||||
@@ -413,6 +493,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
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.8.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
@@ -26,6 +26,7 @@ dependencies:
|
||||
|
||||
# Navigation
|
||||
go_router: ^13.2.1
|
||||
flutter_local_notifications: ^21.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user