diff --git a/assets/images/Online Delivery Service.png b/assets/images/Online Delivery Service.png index 7c42a8a..c4cc1c2 100644 Binary files a/assets/images/Online Delivery Service.png and b/assets/images/Online Delivery Service.png differ diff --git a/assets/images/Packaging For Delivery.png b/assets/images/Packaging For Delivery.png index d1d0f32..6341418 100644 Binary files a/assets/images/Packaging For Delivery.png and b/assets/images/Packaging For Delivery.png differ diff --git a/assets/images/myOrders.png b/assets/images/myOrders.png new file mode 100644 index 0000000..2aaff04 Binary files /dev/null and b/assets/images/myOrders.png differ diff --git a/assets/images/recievedOrders.png b/assets/images/recievedOrders.png new file mode 100644 index 0000000..8e38368 Binary files /dev/null and b/assets/images/recievedOrders.png differ diff --git a/lib/app/config/auth_storage/sign_out.dart b/lib/app/config/auth_storage/sign_out.dart index d2ba99e..99e5fbe 100644 --- a/lib/app/config/auth_storage/sign_out.dart +++ b/lib/app/config/auth_storage/sign_out.dart @@ -6,6 +6,7 @@ import 'package:wassalha/features/address/presentation/manger/address_cubit.dart import 'package:wassalha/features/createOrder/presentation/manger/create_order_cubit.dart'; import 'package:wassalha/features/notifications/presentation/manager/notifications_cubit.dart'; import 'package:wassalha/features/orders/presentation/manger/orders_cubit.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_cubit.dart'; import 'package:wassalha/features/profile/presentation/manger/profile_cubit.dart'; import 'package:wassalha/features/wallet/data/datascources/wallet_local_datasource.dart'; import 'package:wassalha/features/wallet/presentation/manger/wallet_cubit.dart'; @@ -23,6 +24,7 @@ Future signOut() async { await _resetUserCubit(); await _resetUserCubit(); await _resetUserCubit(); + await _resetUserCubit(); await _resetUserCubit(); await _resetUserCubit(); await _resetUserCubit(); diff --git a/lib/app/config/di/di.config.dart b/lib/app/config/di/di.config.dart index e1c0961..1e1a733 100644 --- a/lib/app/config/di/di.config.dart +++ b/lib/app/config/di/di.config.dart @@ -130,15 +130,29 @@ import '../../../features/orders/data/repo/order_repo_impl.dart' as _i272; import '../../../features/orders/domain/repo/order_repo.dart' as _i575; import '../../../features/orders/domain/usecase/cancel_order_usecase.dart' as _i752; +import '../../../features/orders/domain/usecase/confirm_receiver_order_usecase.dart' + as _i408; import '../../../features/orders/domain/usecase/delete_order_usecase.dart' as _i805; import '../../../features/orders/domain/usecase/get_my_orders_usecase.dart' as _i604; import '../../../features/orders/domain/usecase/get_order_by_id_usecase.dart' as _i76; +import '../../../features/orders/domain/usecase/get_receiver_order_by_id_usecase.dart' + as _i489; +import '../../../features/orders/domain/usecase/get_receiver_orders_by_status_usecase.dart' + as _i622; +import '../../../features/orders/domain/usecase/get_receiver_orders_usecase.dart' + as _i501; +import '../../../features/orders/domain/usecase/get_sender_orders_by_status_usecase.dart' + as _i865; import '../../../features/orders/domain/usecase/search_orders_usecase.dart' as _i94; +import '../../../features/orders/domain/usecase/search_receiver_orders_usecase.dart' + as _i9; import '../../../features/orders/presentation/manger/orders_cubit.dart' as _i4; +import '../../../features/orders/presentation/manger/receiver_orders_cubit.dart' + as _i4; import '../../../features/profile/api/dataSources/profile_remote_data_imp.dart' as _i36; import '../../../features/profile/data/datascources/profile_remote_datasource.dart' @@ -435,6 +449,9 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i752.CancelOrderUseCase>( () => _i752.CancelOrderUseCase(gh<_i575.OrderRepository>()), ); + gh.factory<_i408.ConfirmReceiverOrderUseCase>( + () => _i408.ConfirmReceiverOrderUseCase(gh<_i575.OrderRepository>()), + ); gh.factory<_i805.DeleteOrderUseCase>( () => _i805.DeleteOrderUseCase(gh<_i575.OrderRepository>()), ); @@ -444,9 +461,24 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i76.GetOrderByIdUseCase>( () => _i76.GetOrderByIdUseCase(gh<_i575.OrderRepository>()), ); + gh.factory<_i489.GetReceiverOrderByIdUseCase>( + () => _i489.GetReceiverOrderByIdUseCase(gh<_i575.OrderRepository>()), + ); + gh.factory<_i622.GetReceiverOrdersByStatusUseCase>( + () => _i622.GetReceiverOrdersByStatusUseCase(gh<_i575.OrderRepository>()), + ); + gh.factory<_i501.GetReceiverOrdersUseCase>( + () => _i501.GetReceiverOrdersUseCase(gh<_i575.OrderRepository>()), + ); + gh.factory<_i865.GetSenderOrdersByStatusUseCase>( + () => _i865.GetSenderOrdersByStatusUseCase(gh<_i575.OrderRepository>()), + ); gh.factory<_i94.SearchOrdersUseCase>( () => _i94.SearchOrdersUseCase(gh<_i575.OrderRepository>()), ); + gh.factory<_i9.SearchReceiverOrdersUseCase>( + () => _i9.SearchReceiverOrdersUseCase(gh<_i575.OrderRepository>()), + ); gh.factory<_i70.ForgetPasswordCubit>( () => _i70.ForgetPasswordCubit(gh<_i365.ForgetPasswordUseCase>()), ); @@ -456,6 +488,17 @@ extension GetItInjectableX on _i174.GetIt { gh<_i453.ResendResetOtpUseCase>(), ), ); + gh.lazySingleton<_i4.OrdersCubit>( + () => _i4.OrdersCubit( + gh<_i604.GetMyOrdersUseCase>(), + gh<_i94.SearchOrdersUseCase>(), + gh<_i865.GetSenderOrdersByStatusUseCase>(), + gh<_i76.GetOrderByIdUseCase>(), + gh<_i805.DeleteOrderUseCase>(), + gh<_i752.CancelOrderUseCase>(), + gh<_i490.ProfileCubit>(), + ), + ); gh.factory<_i899.ChangePasswordCubit>( () => _i899.ChangePasswordCubit(gh<_i100.ChangePasswordUseCase>()), ); @@ -472,13 +515,13 @@ extension GetItInjectableX on _i174.GetIt { gh<_i1031.EstimatePriceUseCase>(), ), ); - gh.lazySingleton<_i4.OrdersCubit>( - () => _i4.OrdersCubit( - gh<_i604.GetMyOrdersUseCase>(), - gh<_i94.SearchOrdersUseCase>(), - gh<_i76.GetOrderByIdUseCase>(), - gh<_i805.DeleteOrderUseCase>(), - gh<_i752.CancelOrderUseCase>(), + gh.lazySingleton<_i4.ReceiverOrdersCubit>( + () => _i4.ReceiverOrdersCubit( + gh<_i501.GetReceiverOrdersUseCase>(), + gh<_i622.GetReceiverOrdersByStatusUseCase>(), + gh<_i9.SearchReceiverOrdersUseCase>(), + gh<_i489.GetReceiverOrderByIdUseCase>(), + gh<_i408.ConfirmReceiverOrderUseCase>(), gh<_i490.ProfileCubit>(), ), ); diff --git a/lib/app/core/api_manger/api_client.dart b/lib/app/core/api_manger/api_client.dart index 3fcab50..6176578 100644 --- a/lib/app/core/api_manger/api_client.dart +++ b/lib/app/core/api_manger/api_client.dart @@ -153,6 +153,51 @@ abstract class ApiClient { @Path("orderId") int orderId, ); + @GET(AppEndpointString.myReceiverOrders) + Future> getMyReceiverOrders({ + @Query("term") String? term, + @Query("page") int? page, + @Query("size") int? size, + @Query("sort") String? sort, + }); + + @GET('${AppEndpointString.receiverOrdersByUser}/{userId}/receiver/all') + Future> getReceiverOrdersByUser( + @Path("userId") int userId, { + @Query("page") int? page, + @Query("size") int? size, + @Query("sort") String? sort, + }); + + @GET('${AppEndpointString.receiverOrderById}/{id}') + Future> getReceiverOrderById( + @Path("id") int id, + ); + + @POST('${AppEndpointString.receiverConfirmOrder}/{orderId}/confirm') + Future> confirmReceiverOrder( + @Path("orderId") int orderId, + @Query("accept") bool accept, + ); + + @GET('${AppEndpointString.senderOrdersByUser}/{userId}/sender/status/{status}') + Future> getSenderOrdersByStatus( + @Path("userId") int userId, + @Path("status") String status, { + @Query("page") int? page, + @Query("size") int? size, + @Query("sort") String? sort, + }); + + @GET('${AppEndpointString.receiverOrdersByUser}/{userId}/receiver/status/{status}') + Future> getReceiverOrdersByStatus( + @Path("userId") int userId, + @Path("status") String status, { + @Query("page") int? page, + @Query("size") int? size, + @Query("sort") String? sort, + }); + @POST(AppEndpointString.updateNotificationToken) Future> updateNotificationToken( @Body() Map request, diff --git a/lib/app/core/api_manger/api_client.g.dart b/lib/app/core/api_manger/api_client.g.dart index b3fb6a2..7183a67 100644 --- a/lib/app/core/api_manger/api_client.g.dart +++ b/lib/app/core/api_manger/api_client.g.dart @@ -716,6 +716,220 @@ class _ApiClient implements ApiClient { return httpResponse; } + @override + Future> getMyReceiverOrders({ + String? term, + int? page, + int? size, + String? sort, + }) async { + final _extra = {}; + final queryParameters = { + r'term': term, + r'page': page, + r'size': size, + r'sort': sort, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/receiver/search', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrdersListResponse _value; + try { + _value = OrdersListResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getReceiverOrdersByUser( + int userId, { + int? page, + int? size, + String? sort, + }) async { + final _extra = {}; + final queryParameters = { + r'page': page, + r'size': size, + r'sort': sort, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/user/${userId}/receiver/all', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrdersListResponse _value; + try { + _value = OrdersListResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getReceiverOrderById(int id) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/receiver/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrderDetailResponse _value; + try { + _value = OrderDetailResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> confirmReceiverOrder( + int orderId, + bool accept, + ) async { + final _extra = {}; + final queryParameters = {r'accept': accept}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/receiver/${orderId}/confirm', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrderDetailResponse _value; + try { + _value = OrderDetailResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getSenderOrdersByStatus( + int userId, + String status, { + int? page, + int? size, + String? sort, + }) async { + final _extra = {}; + final queryParameters = { + r'page': page, + r'size': size, + r'sort': sort, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/user/${userId}/sender/status/${status}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrdersListResponse _value; + try { + _value = OrdersListResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getReceiverOrdersByStatus( + int userId, + String status, { + int? page, + int? size, + String? sort, + }) async { + final _extra = {}; + final queryParameters = { + r'page': page, + r'size': size, + r'sort': sort, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/user/${userId}/receiver/status/${status}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrdersListResponse _value; + try { + _value = OrdersListResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + @override Future> updateNotificationToken( Map request, diff --git a/lib/app/core/app_constants.dart b/lib/app/core/app_constants.dart index 5dd9d8c..dd3207b 100644 --- a/lib/app/core/app_constants.dart +++ b/lib/app/core/app_constants.dart @@ -99,6 +99,10 @@ class AppConstants { static const String homeSendSomethingAR = 'أرسل شيئاً'; static const String homeDeliverEarn = 'Deliver & Earn'; static const String homeDeliverEarnAR = 'وصل واربح'; + static const String homeMyOrders = 'My Orders'; + static const String homeMyOrdersAR = 'طلباتي'; + static const String homeReceivingOrders = 'Receiving Orders'; + static const String homeReceivingOrdersAR = 'الطلبات المستلمة'; static const String homePopularOrders = 'Popular Orders'; static const String homePopularOrdersAR = 'الطلبات الشائعة'; static const String homeYourOrders = 'Your Orders'; @@ -133,6 +137,33 @@ class AppConstants { static const String myDeliversAR = 'توصيلاتي'; static const String myReceives = 'My Receives'; static const String myReceivesAR = 'استلاماتي'; + static const String yourReceives = 'Your receives'; + static const String yourReceivesAR = 'استلاماتك'; + static const String searchYourReceives = 'Search your receives'; + static const String searchYourReceivesAR = 'ابحث في استلاماتك'; + static const String noReceivesYet = 'No receives yet'; + static const String noReceivesYetAR = 'لا توجد استلامات بعد'; + static const String accept = 'Accept'; + static const String acceptAR = 'قبول'; + static const String decline = 'Decline'; + static const String declineAR = 'رفض'; + static const String acceptOrderConfirm = + 'Do you want to accept this order?'; + static const String acceptOrderConfirmAR = 'هل تريد قبول هذا الطلب؟'; + static const String declineOrderConfirm = + 'Decline this order? It will return to the sender for editing.'; + static const String declineOrderConfirmAR = + 'هل تريد رفض هذا الطلب؟ سيُعاد إلى المرسل لتعديله.'; + static const String orderAccepted = 'Order accepted'; + static const String orderAcceptedAR = 'تم قبول الطلب'; + static const String orderDeclined = 'Order declined'; + static const String orderDeclinedAR = 'تم رفض الطلب'; + static const String cancelOrder = 'Cancel Order'; + static const String cancelOrderAR = 'إلغاء الطلب'; + static const String cancelOrderConfirmSender = + 'Are you sure you want to cancel this order?'; + static const String cancelOrderConfirmSenderAR = + 'هل أنت متأكد أنك تريد إلغاء هذا الطلب؟'; static const String addresses = 'Addresses'; static const String addressesAR = 'العناوين'; static const String settings = 'Settings'; @@ -631,8 +662,24 @@ We retain data only as long as necessary for legal obligations or service improv static const String statusWaitingAR = 'في انتظار التوصيل'; static const String statusAwaitingReceiver = 'Awaiting Receiver'; static const String statusAwaitingReceiverAR = 'في انتظار المستلم'; + static const String statusDeclined = 'Declined'; + static const String statusDeclinedAR = 'مرفوض'; static const String filterAwaitingReceiver = 'Awaiting Receiver'; static const String filterAwaitingReceiverAR = 'في انتظار المستلم'; + static const String filterDeclined = 'Declined'; + static const String filterDeclinedAR = 'مرفوض'; + static const String declinedTitle = 'Declined by Receiver'; + static const String declinedTitleAR = 'تم رفضه من المستلم'; + static const String declinedHint = + 'The receiver declined this order. Update the receiver email and resubmit.'; + static const String declinedHintAR = + 'قام المستلم برفض هذا الطلب. حدّث بريد المستلم وأعد الإرسال.'; + static const String updateAndResubmit = 'Resubmit'; + static const String updateAndResubmitAR = 'إعادة الإرسال'; + static const String mustChangeReceiverEmail = + 'This receiver declined the order. Enter a different email to resubmit.'; + static const String mustChangeReceiverEmailAR = + 'رفض هذا المستلم الطلب. أدخل بريدًا مختلفًا لإعادة الإرسال.'; static const String awaitingReceiverTitle = 'Awaiting Receiver Confirmation'; static const String awaitingReceiverTitleAR = 'في انتظار تأكيد المستلم'; static const String awaitingReceiverHint = diff --git a/lib/app/core/router/app_router.dart b/lib/app/core/router/app_router.dart index 3277545..5879a10 100644 --- a/lib/app/core/router/app_router.dart +++ b/lib/app/core/router/app_router.dart @@ -43,7 +43,10 @@ import 'package:wassalha/features/orders/presentation/pages/order_details_page.d import 'package:wassalha/features/orders/presentation/pages/order_info_page.dart'; import 'package:wassalha/features/createOrder/presentation/pages/submit_order_page.dart'; import 'package:wassalha/features/orders/presentation/pages/your_orders_page.dart'; +import 'package:wassalha/features/orders/presentation/pages/your_receives_page.dart'; +import 'package:wassalha/features/orders/presentation/pages/receiver_order_details_page.dart'; import 'package:wassalha/features/orders/presentation/manger/orders_cubit.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_cubit.dart'; import 'package:wassalha/features/orders/domain/model/order_entity.dart'; @@ -376,5 +379,30 @@ final GoRouter appRouter = GoRouter( return PageTransitions.slide(state, OrderInfoPage(order: order)); }, ), + GoRoute( + path: RouteNames.yourReceives, + pageBuilder: (context, state) => PageTransitions.slide( + state, + BlocProvider.value( + value: getIt(), + child: const YourReceivesPage(), + ), + ), + ), + GoRoute( + path: RouteNames.receiverOrderDetails, + redirect: (context, state) => + state.extra is int ? null : RouteNames.yourReceives, + pageBuilder: (context, state) { + final id = state.extra as int; + return PageTransitions.slide( + state, + BlocProvider.value( + value: getIt(), + child: ReceiverOrderDetailsPage(orderId: id), + ), + ); + }, + ), ], ); diff --git a/lib/app/core/router/route_names.dart b/lib/app/core/router/route_names.dart index ea9f581..453b399 100644 --- a/lib/app/core/router/route_names.dart +++ b/lib/app/core/router/route_names.dart @@ -32,5 +32,7 @@ abstract class RouteNames { static const String yourOrders = '/orders'; static const String orderDetails = '/orders/details'; static const String orderInfo = '/orders/info'; + static const String yourReceives = '/receives'; + static const String receiverOrderDetails = '/receives/details'; } diff --git a/lib/app/core/services/notification_service.dart b/lib/app/core/services/notification_service.dart index e4b0a8e..b447e2e 100644 --- a/lib/app/core/services/notification_service.dart +++ b/lib/app/core/services/notification_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:injectable/injectable.dart'; import 'package:wassalha/app/config/auth_storage/auth_storage.dart'; @@ -10,6 +11,8 @@ import 'package:wassalha/app/core/router/route_names.dart'; import 'package:wassalha/features/notifications/domain/usecases/update_notification_token_usecase.dart'; import 'package:wassalha/features/notifications/presentation/manager/notifications_cubit.dart'; import 'package:wassalha/features/notifications/presentation/manager/notifications_intent.dart'; +import 'package:wassalha/features/orders/presentation/manger/orders_cubit.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_cubit.dart'; @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { @@ -86,16 +89,29 @@ class NotificationService { ); // 6. Listen to Foreground Messages - FirebaseMessaging.onMessage.listen((RemoteMessage message) { + FirebaseMessaging.onMessage.listen((RemoteMessage message) async { final notification = message.notification; final data = message.data; - // Refresh the unread count and inbox list automatically - if (getIt.isRegistered()) { + + final token = await _authStorage.getToken(); + final loggedIn = token != null && token.isNotEmpty; + + if (loggedIn && getIt.isRegistered()) { getIt().doIntent(GetUnreadCountIntent()); getIt().doIntent(GetNotificationsIntent(isRefresh: true)); } + + if (loggedIn && _isOrderRelated(data)) { + if (getIt.isRegistered()) { + getIt().refreshActiveView(); + } + if (getIt.isRegistered()) { + getIt().refreshActiveView(); + } + } + if (notification != null) { _showLocalNotification( id: notification.hashCode, @@ -111,11 +127,13 @@ class NotificationService { _handleNotificationClick(message.data); }); - // 8. Listen to Terminated state Click (when app was closed and opened via notification) + // 8. Listen to Terminated state Click. Deferred to the next frame so the + // router has fully mounted before we try to navigate. FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage? message) { - if (message != null) { + if (message == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { _handleNotificationClick(message.data); - } + }); }); // 9. Sync Token automatically when refreshed by Firebase @@ -189,4 +207,14 @@ class NotificationService { void _handleNotificationClick(Map data) { appRouter.go(RouteNames.appSections, extra: 1); } + + + bool _isOrderRelated(Map data) { + final type = (data['type'] ?? '').toString().toUpperCase(); + if (type.contains('ORDER')) return true; + final orderId = (data['orderId'] ?? '').toString(); + if (orderId.isNotEmpty) return true; + final refId = (data['referenceId'] ?? '').toString(); + return refId.isNotEmpty && type.isNotEmpty; + } } diff --git a/lib/app/core/ui_helper/assets/images.dart b/lib/app/core/ui_helper/assets/images.dart index 7c7ab43..00424d0 100644 --- a/lib/app/core/ui_helper/assets/images.dart +++ b/lib/app/core/ui_helper/assets/images.dart @@ -18,6 +18,8 @@ class Assets { "assets/images/Packaging For Delivery.png"; static const String imagesOnlineDeliveryService = "assets/images/Online Delivery Service.png"; + static const String imagesMyOrders = "assets/images/myOrders.png"; + static const String imagesReceivedOrders = "assets/images/recievedOrders.png"; static const String iconsLocation = "assets/icons/Location.png"; static const String iconsNotification = "assets/icons/notifcation.png"; diff --git a/lib/app/core/values/app_endpoint_strings.dart b/lib/app/core/values/app_endpoint_strings.dart index dd85556..dcb113c 100644 --- a/lib/app/core/values/app_endpoint_strings.dart +++ b/lib/app/core/values/app_endpoint_strings.dart @@ -32,6 +32,12 @@ class AppEndpointString { static const String updateSenderOrder = 'orders/sender/update'; static const String deleteSenderOrder = 'orders/sender/delete'; static const String cancelSenderOrder = 'orders/sender/cancel'; + + static const String myReceiverOrders = 'orders/receiver/search'; + static const String receiverOrdersByUser = 'orders/user'; + static const String receiverOrderById = 'orders/receiver'; + static const String receiverConfirmOrder = 'orders/receiver'; + static const String pricingPredict = 'pricing/predict'; static const String googleGeocodeUrl = diff --git a/lib/features/createOrder/create_order_constants.dart b/lib/features/createOrder/create_order_constants.dart index c4e41fe..00333ac 100644 --- a/lib/features/createOrder/create_order_constants.dart +++ b/lib/features/createOrder/create_order_constants.dart @@ -1,9 +1,9 @@ class CreateOrderConstants { CreateOrderConstants._(); - static const int ordersPageSize = 50; - static const int ordersFirstPage = 0; + static const int ordersPageSize = 20; static const String ordersDefaultSort = 'id,desc'; + static const double ordersLoadMoreThreshold = 240; static const String orderPartName = 'order'; static const String imagePartName = 'image'; diff --git a/lib/features/createOrder/presentation/pages/order_details_step2_page.dart b/lib/features/createOrder/presentation/pages/order_details_step2_page.dart index d427765..160b2d7 100644 --- a/lib/features/createOrder/presentation/pages/order_details_step2_page.dart +++ b/lib/features/createOrder/presentation/pages/order_details_step2_page.dart @@ -93,7 +93,24 @@ class _OrderDetailsStep2PageState extends State { return null; } - String? _email(String? v) => Validators.emailValidator(v?.trim()); + String? _email(String? v) { + final trimmed = v?.trim() ?? ''; + final base = Validators.emailValidator(trimmed); + if (base != null) return base; + + + final blocked = context.read().state.draft + .blockedReceiverEmail; + if (blocked != null && + blocked.isNotEmpty && + trimmed.toLowerCase() == blocked.toLowerCase()) { + final isEnglish = context.locale.languageCode == 'en'; + return isEnglish + ? AppConstants.mustChangeReceiverEmail + : AppConstants.mustChangeReceiverEmailAR; + } + return null; + } void _onNext() { final isEnglish = context.locale.languageCode == 'en'; diff --git a/lib/features/createOrder/presentation/pages/submit_order_page.dart b/lib/features/createOrder/presentation/pages/submit_order_page.dart index 1877215..199d985 100644 --- a/lib/features/createOrder/presentation/pages/submit_order_page.dart +++ b/lib/features/createOrder/presentation/pages/submit_order_page.dart @@ -64,13 +64,18 @@ class _SubmitOrderPageState extends State { final cubit = context.read(); final orders = context.read(); final savedOrder = state.submitResource.data; + final wasEditing = state.draft.isEditing; if (savedOrder != null) { orders.doIntent( - state.draft.isEditing + wasEditing ? orders_intent.ReplaceLocalOrder(savedOrder) : orders_intent.InsertLocalOrder(savedOrder), ); } + + if (!wasEditing) { + orders.doIntent(orders_intent.LoadMyOrders()); + } cubit.doIntent(ClearDraft()); cubit.doIntent(ResetSubmitStatus()); cubit.doIntent(ResetPriceStatus()); diff --git a/lib/features/home/presentation/widgets/homeBody.dart b/lib/features/home/presentation/widgets/homeBody.dart index 17eaede..6c9b3a2 100644 --- a/lib/features/home/presentation/widgets/homeBody.dart +++ b/lib/features/home/presentation/widgets/homeBody.dart @@ -23,6 +23,8 @@ class HomeBody extends StatefulWidget { } class _HomeBodyState extends State { + String? _precachedAvatarUrl; + @override void initState() { super.initState(); @@ -38,6 +40,15 @@ class _HomeBodyState extends State { }); } + void _maybePrecacheAvatar(BuildContext context, String? url) { + if (url == null || url.isEmpty) return; + if (_precachedAvatarUrl == url) return; + _precachedAvatarUrl = url; + precacheImage(NetworkImage(url), context).catchError((_) { + _precachedAvatarUrl = null; + }); + } + @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; @@ -59,6 +70,10 @@ class _HomeBodyState extends State { return BlocBuilder( bloc: getIt(), builder: (context, profileState) { + _maybePrecacheAvatar( + context, + profileState.profileResource.data?.profileImageUrl, + ); return SingleChildScrollView( padding: EdgeInsets.zero, child: Column( diff --git a/lib/features/home/presentation/widgets/service_categories_row.dart b/lib/features/home/presentation/widgets/service_categories_row.dart index 9b51be3..0455c68 100644 --- a/lib/features/home/presentation/widgets/service_categories_row.dart +++ b/lib/features/home/presentation/widgets/service_categories_row.dart @@ -6,12 +6,17 @@ import 'package:wassalha/app/config/order_preferences/order_preferences.dart'; import 'package:wassalha/app/core/app_constants.dart'; import 'package:wassalha/app/core/router/route_names.dart'; import 'package:wassalha/app/core/ui_helper/assets/images.dart'; +import 'package:wassalha/features/createOrder/presentation/manger/create_order_cubit.dart'; +import 'package:wassalha/features/createOrder/presentation/manger/create_order_intent.dart'; import 'package:wassalha/features/home/presentation/widgets/service_category_card.dart'; class ServiceCategoriesRow extends StatelessWidget { const ServiceCategoriesRow({super.key}); Future _startNewOrder(BuildContext context) async { + + getIt().doIntent(ClearDraft()); + final accepted = await getIt().hasAcceptedProhibited(); if (!context.mounted) return; context.push( @@ -25,26 +30,54 @@ class ServiceCategoriesRow extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + child: Column( children: [ - Expanded( - child: ServiceCategoryCard( - label: isEnglish - ? AppConstants.homeSendSomething - : AppConstants.homeSendSomethingAR, - imagePath: Assets.imagesPackagingForDelivery, - onTap: () => _startNewOrder(context), - ), + Row( + children: [ + Expanded( + child: ServiceCategoryCard( + label: isEnglish + ? AppConstants.homeSendSomething + : AppConstants.homeSendSomethingAR, + imagePath: Assets.imagesPackagingForDelivery, + onTap: () => _startNewOrder(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ServiceCategoryCard( + label: isEnglish + ? AppConstants.homeDeliverEarn + : AppConstants.homeDeliverEarnAR, + imagePath: Assets.imagesOnlineDeliveryService, + onTap: () {}, + ), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: ServiceCategoryCard( - label: isEnglish - ? AppConstants.homeDeliverEarn - : AppConstants.homeDeliverEarnAR, - imagePath: Assets.imagesOnlineDeliveryService, - onTap: () {}, - ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ServiceCategoryCard( + label: isEnglish + ? AppConstants.homeMyOrders + : AppConstants.homeMyOrdersAR, + imagePath: Assets.imagesMyOrders, + onTap: () => context.push(RouteNames.yourOrders), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ServiceCategoryCard( + label: isEnglish + ? AppConstants.homeReceivingOrders + : AppConstants.homeReceivingOrdersAR, + imagePath: Assets.imagesReceivedOrders, + onTap: () => context.push(RouteNames.yourReceives), + ), + ), + ], ), ], ), diff --git a/lib/features/orders/data/datascources/order_remote_datasource.dart b/lib/features/orders/data/datascources/order_remote_datasource.dart index a16502a..c362841 100644 --- a/lib/features/orders/data/datascources/order_remote_datasource.dart +++ b/lib/features/orders/data/datascources/order_remote_datasource.dart @@ -1,4 +1,4 @@ -import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; import 'package:wassalha/features/orders/data/model/request/create_order_request.dart'; import 'package:wassalha/features/orders/data/model/request/estimate_price_request.dart'; import 'package:wassalha/features/orders/data/model/response/create_order_response.dart'; @@ -12,8 +12,11 @@ abstract class OrderRemoteDataSource { CreateOrderRequest request, { String? imagePath, }); - Future> getMyOrders(int userId); - Future> searchOrders(String term); + Future> getMyOrders(int userId, {int page = 0}); + Future> searchOrders( + String term, { + int page = 0, + }); Future> estimatePrice( EstimatePriceRequest request, ); @@ -25,4 +28,29 @@ abstract class OrderRemoteDataSource { }); Future> deleteOrder(int id); Future> cancelOrder(int id); -} \ No newline at end of file + + Future> getReceiverOrdersByUser( + int userId, { + int page = 0, + }); + Future> searchReceiverOrders( + String term, { + int page = 0, + }); + Future> getReceiverOrderById(int id); + Future> confirmReceiverOrder( + int orderId, { + required bool accept, + }); + + Future> getSenderOrdersByStatus( + int userId, + String status, { + int page = 0, + }); + Future> getReceiverOrdersByStatus( + int userId, + String status, { + int page = 0, + }); +} diff --git a/lib/features/orders/data/impl/order_remote_data_imp.dart b/lib/features/orders/data/impl/order_remote_data_imp.dart index 2a0577b..f7d7572 100644 --- a/lib/features/orders/data/impl/order_remote_data_imp.dart +++ b/lib/features/orders/data/impl/order_remote_data_imp.dart @@ -87,11 +87,11 @@ class OrderRemoteDataSourceImpl implements OrderRemoteDataSource { } @override - Future> getMyOrders(int userId) { + Future> getMyOrders(int userId, {int page = 0}) { return safeApiCall( call: () => _apiClient.getSenderOrdersByUser( userId, - page: CreateOrderConstants.ordersFirstPage, + page: page, size: CreateOrderConstants.ordersPageSize, sort: CreateOrderConstants.ordersDefaultSort, ), @@ -99,11 +99,95 @@ class OrderRemoteDataSourceImpl implements OrderRemoteDataSource { } @override - Future> searchOrders(String term) { + Future> searchOrders( + String term, { + int page = 0, + }) { return safeApiCall( call: () => _apiClient.getMySenderOrders( term: term, - page: CreateOrderConstants.ordersFirstPage, + page: page, + size: CreateOrderConstants.ordersPageSize, + sort: CreateOrderConstants.ordersDefaultSort, + ), + ); + } + + @override + Future> getReceiverOrdersByUser( + int userId, { + int page = 0, + }) { + return safeApiCall( + call: () => _apiClient.getReceiverOrdersByUser( + userId, + page: page, + size: CreateOrderConstants.ordersPageSize, + sort: CreateOrderConstants.ordersDefaultSort, + ), + ); + } + + @override + Future> searchReceiverOrders( + String term, { + int page = 0, + }) { + return safeApiCall( + call: () => _apiClient.getMyReceiverOrders( + term: term, + page: page, + size: CreateOrderConstants.ordersPageSize, + sort: CreateOrderConstants.ordersDefaultSort, + ), + ); + } + + @override + Future> getReceiverOrderById(int id) { + return safeApiCall( + call: () => _apiClient.getReceiverOrderById(id), + ); + } + + @override + Future> confirmReceiverOrder( + int orderId, { + required bool accept, + }) { + return safeApiCall( + call: () => _apiClient.confirmReceiverOrder(orderId, accept), + ); + } + + @override + Future> getSenderOrdersByStatus( + int userId, + String status, { + int page = 0, + }) { + return safeApiCall( + call: () => _apiClient.getSenderOrdersByStatus( + userId, + status, + page: page, + size: CreateOrderConstants.ordersPageSize, + sort: CreateOrderConstants.ordersDefaultSort, + ), + ); + } + + @override + Future> getReceiverOrdersByStatus( + int userId, + String status, { + int page = 0, + }) { + return safeApiCall( + call: () => _apiClient.getReceiverOrdersByStatus( + userId, + status, + page: page, size: CreateOrderConstants.ordersPageSize, sort: CreateOrderConstants.ordersDefaultSort, ), diff --git a/lib/features/orders/data/repo/order_repo_impl.dart b/lib/features/orders/data/repo/order_repo_impl.dart index b70c06a..16fc9a5 100644 --- a/lib/features/orders/data/repo/order_repo_impl.dart +++ b/lib/features/orders/data/repo/order_repo_impl.dart @@ -11,6 +11,7 @@ import 'package:wassalha/features/orders/data/model/response/order_detail_respon import 'package:wassalha/features/orders/data/model/response/orders_list_response.dart'; import 'package:wassalha/features/orders/domain/model/order_draft.dart'; import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; import 'package:wassalha/features/createOrder/domain/model/order_price_estimate.dart'; import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; @@ -121,26 +122,127 @@ class OrderRepositoryImpl implements OrderRepository { } @override - Future>> getMyOrders(int userId) async { - return _mapOrdersList(await _remoteDataSource.getMyOrders(userId)); + Future> getMyOrders( + int userId, { + int page = 0, + }) async { + return _mapOrdersPage( + await _remoteDataSource.getMyOrders(userId, page: page), + ); + } + + @override + Future> searchOrders( + String term, { + int page = 0, + }) async { + return _mapOrdersPage( + await _remoteDataSource.searchOrders(term, page: page), + ); + } + + @override + Future> getMyReceives( + int userId, { + int page = 0, + }) async { + return _mapReceiverOrdersPage( + await _remoteDataSource.getReceiverOrdersByUser(userId, page: page), + ); + } + + @override + Future> searchReceives( + String term, { + int page = 0, + }) async { + return _mapReceiverOrdersPage( + await _remoteDataSource.searchReceiverOrders(term, page: page), + ); + } + + @override + Future> getReceiverOrderById(int id) async { + return _mapDetailResult(await _remoteDataSource.getReceiverOrderById(id)); + } + + @override + Future> confirmReceiverOrder( + int orderId, { + required bool accept, + }) async { + return _mapDetailResult( + await _remoteDataSource.confirmReceiverOrder(orderId, accept: accept), + ); } @override - Future>> searchOrders(String term) async { - return _mapOrdersList(await _remoteDataSource.searchOrders(term)); + Future> getSenderOrdersByStatus( + int userId, + String status, { + int page = 0, + }) async { + return _mapOrdersPage( + await _remoteDataSource + .getSenderOrdersByStatus(userId, status, page: page), + ); + } + + @override + Future> getReceiverOrdersByStatus( + int userId, + String status, { + int page = 0, + }) async { + return _mapReceiverOrdersPage( + await _remoteDataSource + .getReceiverOrdersByStatus(userId, status, page: page), + ); + } + + + ApiResult _mapReceiverOrdersPage( + ApiResult result, + ) { + final mapped = _mapOrdersPage(result); + if (mapped is SuccessApiResult) { + final page = mapped.data; + final filtered = page.items + .where((o) => o.status != OrderStatus.declined) + .toList(); + return SuccessApiResult( + data: OrdersPageResult( + items: filtered, + pageNumber: page.pageNumber, + isLast: page.isLast, + ), + ); + } + return mapped; } - ApiResult> _mapOrdersList( + ApiResult _mapOrdersPage( ApiResult result, ) { if (result is SuccessApiResult) { - final list = - result.data.data?.content?.map((e) => e.toDomain()).toList() ?? []; - return SuccessApiResult>(data: list); + final page = result.data.data; + final items = + page?.content?.map((e) => e.toDomain()).toList() ?? const []; + final pageNumber = page?.number ?? 0; + final totalPages = page?.totalPages; + final isLast = + page?.last ?? (totalPages == null || pageNumber >= totalPages - 1); + return SuccessApiResult( + data: OrdersPageResult( + items: items, + pageNumber: pageNumber, + isLast: isLast, + ), + ); } else if (result is ErrorApiResult) { - return ErrorApiResult>(error: result.error); + return ErrorApiResult(error: result.error); } - return ErrorApiResult>(error: 'Unknown error'); + return ErrorApiResult(error: 'Unknown error'); } CreateOrderRequest _buildRequest(OrderDraft draft, {bool forUpdate = false}) { diff --git a/lib/features/orders/domain/model/order_draft.dart b/lib/features/orders/domain/model/order_draft.dart index 4586f71..85d3dc5 100644 --- a/lib/features/orders/domain/model/order_draft.dart +++ b/lib/features/orders/domain/model/order_draft.dart @@ -25,6 +25,9 @@ class OrderDraft { final int? editingOrderId; final String? existingImageUrl; + + final String? blockedReceiverEmail; + const OrderDraft({ this.itemName = '', this.description = '', @@ -48,6 +51,7 @@ class OrderDraft { this.imagePath, this.editingOrderId, this.existingImageUrl, + this.blockedReceiverEmail, }); OrderDraft copyWith({ @@ -73,6 +77,7 @@ class OrderDraft { String? imagePath, int? editingOrderId, String? existingImageUrl, + String? blockedReceiverEmail, }) { return OrderDraft( itemName: itemName ?? this.itemName, @@ -97,12 +102,17 @@ class OrderDraft { imagePath: imagePath ?? this.imagePath, editingOrderId: editingOrderId ?? this.editingOrderId, existingImageUrl: existingImageUrl ?? this.existingImageUrl, + blockedReceiverEmail: + blockedReceiverEmail ?? this.blockedReceiverEmail, ); } bool get isEditing => editingOrderId != null; factory OrderDraft.fromOrder(OrderEntity order, {bool forEditing = false}) { + final blocked = forEditing && order.status == OrderStatus.declined + ? order.receiverEmail + : null; return OrderDraft( itemName: order.itemName, description: order.description, @@ -123,6 +133,7 @@ class OrderDraft { finalPrice: order.finalPrice, editingOrderId: forEditing ? order.id : null, existingImageUrl: order.imageUrl, + blockedReceiverEmail: blocked, ); } } \ No newline at end of file diff --git a/lib/features/orders/domain/model/order_entity.dart b/lib/features/orders/domain/model/order_entity.dart index 3b098f5..eee6159 100644 --- a/lib/features/orders/domain/model/order_entity.dart +++ b/lib/features/orders/domain/model/order_entity.dart @@ -1,5 +1,7 @@ enum OrderStatus { + declined, + awaitingReceiver, waiting, @@ -9,8 +11,31 @@ unknown, } + +String? orderStatusToApi(OrderStatus status) { + switch (status) { + case OrderStatus.declined: + return 'DRAFT'; + case OrderStatus.awaitingReceiver: + return 'AWAITING_RECEIVER_CONFIRMATION'; + case OrderStatus.waiting: + return 'WAIT_FOR_DELIVERY'; + case OrderStatus.inTransit: + return 'IN_TRANSIT'; + case OrderStatus.delivered: + return 'DELIVERED'; + case OrderStatus.cancelled: + return 'CANCELLED'; + case OrderStatus.unknown: + return null; + } +} + OrderStatus orderStatusFromApi(String? raw) { switch (raw?.toUpperCase()) { + case 'DRAFT': + return OrderStatus.declined; + case 'AWAITING_RECEIVER_CONFIRMATION': return OrderStatus.awaitingReceiver; diff --git a/lib/features/orders/domain/model/orders_page_result.dart b/lib/features/orders/domain/model/orders_page_result.dart new file mode 100644 index 0000000..4dbc259 --- /dev/null +++ b/lib/features/orders/domain/model/orders_page_result.dart @@ -0,0 +1,13 @@ +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; + +class OrdersPageResult { + final List items; + final int pageNumber; + final bool isLast; + + const OrdersPageResult({ + required this.items, + required this.pageNumber, + required this.isLast, + }); +} diff --git a/lib/features/orders/domain/repo/order_repo.dart b/lib/features/orders/domain/repo/order_repo.dart index c050841..4b53df9 100644 --- a/lib/features/orders/domain/repo/order_repo.dart +++ b/lib/features/orders/domain/repo/order_repo.dart @@ -1,15 +1,38 @@ -import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; import 'package:wassalha/features/orders/domain/model/order_draft.dart'; import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; import 'package:wassalha/features/createOrder/domain/model/order_price_estimate.dart'; abstract class OrderRepository { Future> createOrder(OrderDraft draft); - Future>> getMyOrders(int userId); - Future>> searchOrders(String term); + Future> getMyOrders(int userId, {int page = 0}); + Future> searchOrders(String term, {int page = 0}); Future> estimatePrice(OrderDraft draft); Future> getOrderById(int id); Future> updateOrder(int id, OrderDraft draft); Future> deleteOrder(int id); Future> cancelOrder(int id); -} \ No newline at end of file + + Future> getMyReceives(int userId, {int page = 0}); + Future> searchReceives( + String term, { + int page = 0, + }); + Future> getReceiverOrderById(int id); + Future> confirmReceiverOrder( + int orderId, { + required bool accept, + }); + + Future> getSenderOrdersByStatus( + int userId, + String status, { + int page = 0, + }); + Future> getReceiverOrdersByStatus( + int userId, + String status, { + int page = 0, + }); +} diff --git a/lib/features/orders/domain/usecase/confirm_receiver_order_usecase.dart b/lib/features/orders/domain/usecase/confirm_receiver_order_usecase.dart new file mode 100644 index 0000000..29edc88 --- /dev/null +++ b/lib/features/orders/domain/usecase/confirm_receiver_order_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class ConfirmReceiverOrderUseCase { + final OrderRepository _repository; + + ConfirmReceiverOrderUseCase(this._repository); + + Future> execute(int orderId, {required bool accept}) { + return _repository.confirmReceiverOrder(orderId, accept: accept); + } +} diff --git a/lib/features/orders/domain/usecase/get_my_orders_usecase.dart b/lib/features/orders/domain/usecase/get_my_orders_usecase.dart index 9d75164..c947111 100644 --- a/lib/features/orders/domain/usecase/get_my_orders_usecase.dart +++ b/lib/features/orders/domain/usecase/get_my_orders_usecase.dart @@ -1,6 +1,6 @@ -import 'package:injectable/injectable.dart'; +import 'package:injectable/injectable.dart'; import 'package:wassalha/app/core/network/api_result.dart'; -import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; @injectable @@ -9,7 +9,7 @@ class GetMyOrdersUseCase { GetMyOrdersUseCase(this._repository); - Future>> execute(int userId) { - return _repository.getMyOrders(userId); + Future> execute(int userId, {int page = 0}) { + return _repository.getMyOrders(userId, page: page); } -} \ No newline at end of file +} diff --git a/lib/features/orders/domain/usecase/get_receiver_order_by_id_usecase.dart b/lib/features/orders/domain/usecase/get_receiver_order_by_id_usecase.dart new file mode 100644 index 0000000..db81d46 --- /dev/null +++ b/lib/features/orders/domain/usecase/get_receiver_order_by_id_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class GetReceiverOrderByIdUseCase { + final OrderRepository _repository; + + GetReceiverOrderByIdUseCase(this._repository); + + Future> execute(int id) { + return _repository.getReceiverOrderById(id); + } +} diff --git a/lib/features/orders/domain/usecase/get_receiver_orders_by_status_usecase.dart b/lib/features/orders/domain/usecase/get_receiver_orders_by_status_usecase.dart new file mode 100644 index 0000000..5739027 --- /dev/null +++ b/lib/features/orders/domain/usecase/get_receiver_orders_by_status_usecase.dart @@ -0,0 +1,19 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class GetReceiverOrdersByStatusUseCase { + final OrderRepository _repository; + + GetReceiverOrdersByStatusUseCase(this._repository); + + Future> execute( + int userId, + String status, { + int page = 0, + }) { + return _repository.getReceiverOrdersByStatus(userId, status, page: page); + } +} diff --git a/lib/features/orders/domain/usecase/get_receiver_orders_usecase.dart b/lib/features/orders/domain/usecase/get_receiver_orders_usecase.dart new file mode 100644 index 0000000..81ee89a --- /dev/null +++ b/lib/features/orders/domain/usecase/get_receiver_orders_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class GetReceiverOrdersUseCase { + final OrderRepository _repository; + + GetReceiverOrdersUseCase(this._repository); + + Future> execute(int userId, {int page = 0}) { + return _repository.getMyReceives(userId, page: page); + } +} diff --git a/lib/features/orders/domain/usecase/get_sender_orders_by_status_usecase.dart b/lib/features/orders/domain/usecase/get_sender_orders_by_status_usecase.dart new file mode 100644 index 0000000..4046b36 --- /dev/null +++ b/lib/features/orders/domain/usecase/get_sender_orders_by_status_usecase.dart @@ -0,0 +1,19 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class GetSenderOrdersByStatusUseCase { + final OrderRepository _repository; + + GetSenderOrdersByStatusUseCase(this._repository); + + Future> execute( + int userId, + String status, { + int page = 0, + }) { + return _repository.getSenderOrdersByStatus(userId, status, page: page); + } +} diff --git a/lib/features/orders/domain/usecase/search_orders_usecase.dart b/lib/features/orders/domain/usecase/search_orders_usecase.dart index e94ec3c..f4f8823 100644 --- a/lib/features/orders/domain/usecase/search_orders_usecase.dart +++ b/lib/features/orders/domain/usecase/search_orders_usecase.dart @@ -1,6 +1,6 @@ -import 'package:injectable/injectable.dart'; +import 'package:injectable/injectable.dart'; import 'package:wassalha/app/core/network/api_result.dart'; -import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; @injectable @@ -9,7 +9,7 @@ class SearchOrdersUseCase { SearchOrdersUseCase(this._repository); - Future>> execute(String term) { - return _repository.searchOrders(term); + Future> execute(String term, {int page = 0}) { + return _repository.searchOrders(term, page: page); } -} \ No newline at end of file +} diff --git a/lib/features/orders/domain/usecase/search_receiver_orders_usecase.dart b/lib/features/orders/domain/usecase/search_receiver_orders_usecase.dart new file mode 100644 index 0000000..3533a90 --- /dev/null +++ b/lib/features/orders/domain/usecase/search_receiver_orders_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; +import 'package:wassalha/features/orders/domain/repo/order_repo.dart'; + +@injectable +class SearchReceiverOrdersUseCase { + final OrderRepository _repository; + + SearchReceiverOrdersUseCase(this._repository); + + Future> execute(String term, {int page = 0}) { + return _repository.searchReceives(term, page: page); + } +} diff --git a/lib/features/orders/presentation/manger/orders_cubit.dart b/lib/features/orders/presentation/manger/orders_cubit.dart index 77ef112..4a11069 100644 --- a/lib/features/orders/presentation/manger/orders_cubit.dart +++ b/lib/features/orders/presentation/manger/orders_cubit.dart @@ -1,12 +1,14 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:wassalha/app/config/base_state/base_state.dart'; import 'package:wassalha/app/core/network/api_result.dart'; import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; import 'package:wassalha/features/orders/domain/usecase/cancel_order_usecase.dart'; import 'package:wassalha/features/orders/domain/usecase/delete_order_usecase.dart'; import 'package:wassalha/features/orders/domain/usecase/get_my_orders_usecase.dart'; import 'package:wassalha/features/orders/domain/usecase/get_order_by_id_usecase.dart'; +import 'package:wassalha/features/orders/domain/usecase/get_sender_orders_by_status_usecase.dart'; import 'package:wassalha/features/orders/domain/usecase/search_orders_usecase.dart'; import 'package:wassalha/features/orders/presentation/manger/orders_intent.dart'; import 'package:wassalha/features/orders/presentation/manger/orders_state.dart'; @@ -17,6 +19,7 @@ import 'package:wassalha/features/profile/presentation/manger/profileIntent.dart class OrdersCubit extends Cubit { final GetMyOrdersUseCase _getMyOrdersUseCase; final SearchOrdersUseCase _searchOrdersUseCase; + final GetSenderOrdersByStatusUseCase _getOrdersByStatusUseCase; final GetOrderByIdUseCase _getOrderByIdUseCase; final DeleteOrderUseCase _deleteOrderUseCase; final CancelOrderUseCase _cancelOrderUseCase; @@ -25,16 +28,30 @@ class OrdersCubit extends Cubit { OrdersCubit( this._getMyOrdersUseCase, this._searchOrdersUseCase, + this._getOrdersByStatusUseCase, this._getOrderByIdUseCase, this._deleteOrderUseCase, this._cancelOrderUseCase, this._profileCubit, ) : super(OrdersState()); + /// Monotonic generation counter for list-load requests. Bumped at the + /// start of every fresh first-page load (LoadMyOrders / LoadOrdersByStatus + /// / SearchOrders). Each async branch captures its generation locally and + /// discards its result if the value has moved on by the time the await + /// completes — prevents a stale response from clobbering the current view + /// (e.g. a load-more on "Waiting" landing after the user switched to + /// "Delivered"). + int _listRequestSeq = 0; + void doIntent(OrdersIntent intent) { switch (intent) { case LoadMyOrders(): _loadMyOrders(); + case LoadMoreOrders(): + _loadMore(); + case LoadOrdersByStatus(:final status): + _loadOrdersByStatus(status); case SearchOrders(:final term): _searchOrders(term); case LoadOrderDetail(:final id): @@ -55,16 +72,100 @@ class OrdersCubit extends Cubit { } Future _loadMyOrders() async { - emit(state.copyWith(ordersResource: Resource.loading())); + final mySeq = ++_listRequestSeq; + emit(state.copyWith( + ordersResource: Resource.loading(), + clearActiveFilter: true, + clearActiveSearchTerm: true, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); - var userId = _profileCubit.state.profileResource.data?.id; + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; if (userId == null) { - _profileCubit.doIntent(FetchProfile()); + emit(state.copyWith( + ordersResource: Resource.error('Could not resolve current user'), + )); + return; + } + + final result = await _getMyOrdersUseCase.execute(userId); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); + } + + /// Resolves the current user id. If the profile hasn't been fetched yet, + /// fires a fetch and waits for the resource to settle. Returns null if the + /// profile could not be resolved (or the cubit was disposed mid-wait). + Future _resolveUserId() async { + var userId = _profileCubit.state.profileResource.data?.id; + if (userId != null) return userId; + + _profileCubit.doIntent(FetchProfile()); + try { await _profileCubit.stream.firstWhere( (s) => s.profileResource.isSuccess || s.profileResource.isError, ); - userId = _profileCubit.state.profileResource.data?.id; + } catch (_) { + return null; } + return _profileCubit.state.profileResource.data?.id; + } + + /// Re-fires the last list-load intent (LoadMyOrders or LoadOrdersByStatus) + /// so that incoming FCM events can refresh the view without losing the + /// user's currently-selected filter chip. + void refreshActiveView() { + final filter = state.activeFilter; + if (filter == null) { + doIntent(LoadMyOrders()); + } else { + doIntent(LoadOrdersByStatus(filter)); + } + } + + Future _searchOrders(String term) async { + final trimmed = term.trim(); + if (trimmed.isEmpty) { + await _loadMyOrders(); + return; + } + final mySeq = ++_listRequestSeq; + // Search results aren't scoped to any status — clear the active filter so + // an incoming FCM refresh doesn't snap back into a filtered list view. + emit(state.copyWith( + ordersResource: Resource.loading(), + clearActiveFilter: true, + activeSearchTerm: trimmed, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); + final result = await _searchOrdersUseCase.execute(trimmed); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); + } + + Future _loadOrdersByStatus(OrderStatus status) async { + final apiStatus = orderStatusToApi(status); + if (apiStatus == null) { + await _loadMyOrders(); + return; + } + final mySeq = ++_listRequestSeq; + emit(state.copyWith( + ordersResource: Resource.loading(), + activeFilter: status, + clearActiveSearchTerm: true, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); + + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; if (userId == null) { emit(state.copyWith( ordersResource: Resource.error('Could not resolve current user'), @@ -72,17 +173,59 @@ class OrdersCubit extends Cubit { return; } - final result = await _getMyOrdersUseCase.execute(userId); - _emitOrders(result); + final result = await _getOrdersByStatusUseCase.execute(userId, apiStatus); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); } - Future _searchOrders(String term) async { - if (term.trim().isEmpty) { - await _loadMyOrders(); - return; + Future _loadMore() async { + if (state.isLoadingMore || !state.hasMore) return; + if (!state.ordersResource.isSuccess) return; + + // Capture the current generation. If the user switches filter / fires + // a new search / pulls to refresh while we're awaiting the next page, + // the seq will move on and we'll discard the now-stale response. + final mySeq = _listRequestSeq; + emit(state.copyWith(isLoadingMore: true)); + final nextPage = state.currentPage + 1; + + final ApiResult result; + final term = state.activeSearchTerm; + final filter = state.activeFilter; + + if (term != null && term.isNotEmpty) { + result = await _searchOrdersUseCase.execute(term, page: nextPage); + } else if (filter != null) { + final apiStatus = orderStatusToApi(filter); + if (apiStatus == null) { + if (mySeq == _listRequestSeq) { + emit(state.copyWith(isLoadingMore: false)); + } + return; + } + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + result = await _getOrdersByStatusUseCase.execute( + userId, + apiStatus, + page: nextPage, + ); + } else { + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + result = await _getMyOrdersUseCase.execute(userId, page: nextPage); } - emit(state.copyWith(ordersResource: Resource.loading())); - _emitOrders(await _searchOrdersUseCase.execute(term.trim())); + + if (mySeq != _listRequestSeq) return; + _appendPage(result); } Future _loadOrderDetail(int id) async { @@ -126,13 +269,19 @@ class OrdersCubit extends Cubit { final result = await _cancelOrderUseCase.execute(id); if (result is SuccessApiResult) { - final updated = (state.ordersResource.data ?? const []) - .map((o) => o.id == id ? result.data : o) - .toList(); + final current = state.ordersResource.data ?? const []; + final filter = state.activeFilter; + final updated = result.data; + // If the user is on a status filter the cancelled order no longer + // matches (e.g. "Waiting" → CANCELLED), drop it from the list so it + // doesn't linger under the wrong chip. + final list = filter != null && updated.status != filter + ? current.where((o) => o.id != id).toList() + : current.map((o) => o.id == id ? updated : o).toList(); emit(state.copyWith( orderActionResource: Resource.success(null), - ordersResource: Resource.success(updated), - orderDetailResource: Resource.success(result.data), + ordersResource: Resource.success(list), + orderDetailResource: Resource.success(updated), )); } else if (result is ErrorApiResult) { emit(state.copyWith(orderActionResource: Resource.error(result.error))); @@ -144,25 +293,61 @@ class OrdersCubit extends Cubit { } void _insertLocal(OrderEntity order) { + // If a status filter is active and the new order doesn't match it, the + // user would see a stray row that vanishes on the next refresh. Skip the + // optimistic insert — the next refreshActiveView() will reconcile. + final filter = state.activeFilter; + if (filter != null && order.status != filter) return; + final list = List.from(state.ordersResource.data ?? const []) ..insert(0, order); emit(state.copyWith(ordersResource: Resource.success(list))); } void _replaceLocal(OrderEntity order) { - final list = (state.ordersResource.data ?? const []) - .map((o) => o.id == order.id ? order : o) - .toList(); + final filter = state.activeFilter; + final current = state.ordersResource.data ?? const []; + // If a filter is active and the replaced order has transitioned out of + // the filtered status (e.g. waiting → in_transit while on the "Waiting" + // chip), drop it from the list rather than keeping a row that no longer + // belongs there. + final list = filter != null && order.status != filter + ? current.where((o) => o.id != order.id).toList() + : current.map((o) => o.id == order.id ? order : o).toList(); emit(state.copyWith(ordersResource: Resource.success(list))); } - void _emitOrders(ApiResult> result) { - if (result is SuccessApiResult>) { - emit(state.copyWith(ordersResource: Resource.success(result.data))); - } else if (result is ErrorApiResult>) { + void _emitFirstPage(ApiResult result) { + if (result is SuccessApiResult) { + final page = result.data; + emit(state.copyWith( + ordersResource: Resource.success(page.items), + currentPage: page.pageNumber, + hasMore: !page.isLast, + isLoadingMore: false, + )); + } else if (result is ErrorApiResult) { emit(state.copyWith(ordersResource: Resource.error(result.error))); } else { emit(state.copyWith(ordersResource: Resource.error('Unknown error'))); } } -} \ No newline at end of file + + void _appendPage(ApiResult result) { + if (result is SuccessApiResult) { + final page = result.data; + final current = state.ordersResource.data ?? const []; + final existingIds = current.map((o) => o.id).toSet(); + final additions = + page.items.where((o) => !existingIds.contains(o.id)).toList(); + emit(state.copyWith( + ordersResource: Resource.success([...current, ...additions]), + currentPage: page.pageNumber, + hasMore: !page.isLast, + isLoadingMore: false, + )); + } else { + emit(state.copyWith(isLoadingMore: false)); + } + } +} diff --git a/lib/features/orders/presentation/manger/orders_intent.dart b/lib/features/orders/presentation/manger/orders_intent.dart index 64a8449..179ae5a 100644 --- a/lib/features/orders/presentation/manger/orders_intent.dart +++ b/lib/features/orders/presentation/manger/orders_intent.dart @@ -4,6 +4,13 @@ sealed class OrdersIntent {} class LoadMyOrders extends OrdersIntent {} +class LoadMoreOrders extends OrdersIntent {} + +class LoadOrdersByStatus extends OrdersIntent { + final OrderStatus status; + LoadOrdersByStatus(this.status); +} + class SearchOrders extends OrdersIntent { final String term; SearchOrders(this.term); diff --git a/lib/features/orders/presentation/manger/orders_state.dart b/lib/features/orders/presentation/manger/orders_state.dart index a9e87bf..894e43b 100644 --- a/lib/features/orders/presentation/manger/orders_state.dart +++ b/lib/features/orders/presentation/manger/orders_state.dart @@ -1,17 +1,28 @@ -import 'package:wassalha/app/config/base_state/base_state.dart'; +import 'package:wassalha/app/config/base_state/base_state.dart'; import 'package:wassalha/features/orders/domain/model/order_entity.dart'; class OrdersState { final Resource> ordersResource; - final Resource orderDetailResource; - final Resource orderActionResource; + + final OrderStatus? activeFilter; + + final String? activeSearchTerm; + final int currentPage; + final bool hasMore; + final bool isLoadingMore; + OrdersState({ Resource>? ordersResource, Resource? orderDetailResource, Resource? orderActionResource, + this.activeFilter, + this.activeSearchTerm, + this.currentPage = 0, + this.hasMore = true, + this.isLoadingMore = false, }) : ordersResource = ordersResource ?? Resource.initial(), orderDetailResource = orderDetailResource ?? Resource.initial(), orderActionResource = orderActionResource ?? Resource.initial(); @@ -20,11 +31,26 @@ class OrdersState { Resource>? ordersResource, Resource? orderDetailResource, Resource? orderActionResource, + OrderStatus? activeFilter, + String? activeSearchTerm, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + bool clearActiveFilter = false, + bool clearActiveSearchTerm = false, }) { return OrdersState( ordersResource: ordersResource ?? this.ordersResource, orderDetailResource: orderDetailResource ?? this.orderDetailResource, orderActionResource: orderActionResource ?? this.orderActionResource, + activeFilter: + clearActiveFilter ? null : (activeFilter ?? this.activeFilter), + activeSearchTerm: clearActiveSearchTerm + ? null + : (activeSearchTerm ?? this.activeSearchTerm), + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, ); } -} \ No newline at end of file +} diff --git a/lib/features/orders/presentation/manger/receiver_orders_cubit.dart b/lib/features/orders/presentation/manger/receiver_orders_cubit.dart new file mode 100644 index 0000000..4a272e5 --- /dev/null +++ b/lib/features/orders/presentation/manger/receiver_orders_cubit.dart @@ -0,0 +1,295 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:wassalha/app/config/base_state/base_state.dart'; +import 'package:wassalha/app/core/network/api_result.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/domain/model/orders_page_result.dart'; +import 'package:wassalha/features/createOrder/create_order_constants.dart'; +import 'package:wassalha/features/orders/domain/usecase/confirm_receiver_order_usecase.dart'; +import 'package:wassalha/features/orders/domain/usecase/get_receiver_order_by_id_usecase.dart'; +import 'package:wassalha/features/orders/domain/usecase/get_receiver_orders_by_status_usecase.dart'; +import 'package:wassalha/features/orders/domain/usecase/get_receiver_orders_usecase.dart'; +import 'package:wassalha/features/orders/domain/usecase/search_receiver_orders_usecase.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_intent.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_state.dart'; +import 'package:wassalha/features/profile/presentation/manger/profileIntent.dart'; +import 'package:wassalha/features/profile/presentation/manger/profile_cubit.dart'; + +@lazySingleton +class ReceiverOrdersCubit extends Cubit { + final GetReceiverOrdersUseCase _getReceiverOrdersUseCase; + final GetReceiverOrdersByStatusUseCase _getReceivesByStatusUseCase; + final SearchReceiverOrdersUseCase _searchReceiverOrdersUseCase; + final GetReceiverOrderByIdUseCase _getReceiverOrderByIdUseCase; + final ConfirmReceiverOrderUseCase _confirmReceiverOrderUseCase; + final ProfileCubit _profileCubit; + + ReceiverOrdersCubit( + this._getReceiverOrdersUseCase, + this._getReceivesByStatusUseCase, + this._searchReceiverOrdersUseCase, + this._getReceiverOrderByIdUseCase, + this._confirmReceiverOrderUseCase, + this._profileCubit, + ) : super(ReceiverOrdersState()); + + int _listRequestSeq = 0; + + void doIntent(ReceiverOrdersIntent intent) { + switch (intent) { + case LoadMyReceives(): + _loadMyReceives(); + case LoadMoreReceives(): + _loadMore(); + case LoadReceivesByStatus(:final status): + _loadReceivesByStatus(status); + case SearchReceives(:final term): + _searchReceives(term); + case LoadReceiverOrderDetail(:final id): + _loadOrderDetail(id); + case ConfirmReceiverOrder(:final id, :final accept): + _confirm(id, accept: accept); + case ResetReceiverOrderDetail(): + emit(state.copyWith(orderDetailResource: Resource.initial())); + case ResetReceiverConfirm(): + emit(state.copyWith(confirmResource: Resource.initial())); + } + } + + Future _loadMyReceives() async { + final mySeq = ++_listRequestSeq; + emit(state.copyWith( + ordersResource: Resource.loading(), + clearActiveFilter: true, + clearActiveSearchTerm: true, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); + + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith( + ordersResource: Resource.error('Could not resolve current user'), + )); + return; + } + + final result = await _getReceiverOrdersUseCase.execute(userId); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); + } + + + Future _resolveUserId() async { + var userId = _profileCubit.state.profileResource.data?.id; + if (userId != null) return userId; + + _profileCubit.doIntent(FetchProfile()); + try { + await _profileCubit.stream.firstWhere( + (s) => s.profileResource.isSuccess || s.profileResource.isError, + ); + } catch (_) { + return null; + } + return _profileCubit.state.profileResource.data?.id; + } + + + void refreshActiveView() { + final filter = state.activeFilter; + if (filter == null) { + doIntent(LoadMyReceives()); + } else { + doIntent(LoadReceivesByStatus(filter)); + } + } + + Future _searchReceives(String term) async { + final trimmed = term.trim(); + if (trimmed.isEmpty) { + await _loadMyReceives(); + return; + } + final mySeq = ++_listRequestSeq; + emit(state.copyWith( + ordersResource: Resource.loading(), + clearActiveFilter: true, + activeSearchTerm: trimmed, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); + final result = await _searchReceiverOrdersUseCase.execute(trimmed); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); + } + + Future _loadReceivesByStatus(OrderStatus status) async { + final apiStatus = orderStatusToApi(status); + if (apiStatus == null) { + await _loadMyReceives(); + return; + } + final mySeq = ++_listRequestSeq; + emit(state.copyWith( + ordersResource: Resource.loading(), + activeFilter: status, + clearActiveSearchTerm: true, + currentPage: 0, + hasMore: true, + isLoadingMore: false, + )); + + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith( + ordersResource: Resource.error('Could not resolve current user'), + )); + return; + } + + final result = await _getReceivesByStatusUseCase.execute(userId, apiStatus); + if (mySeq != _listRequestSeq) return; + _emitFirstPage(result); + } + + Future _loadMore() async { + if (state.isLoadingMore || !state.hasMore) return; + if (!state.ordersResource.isSuccess) return; + + final mySeq = _listRequestSeq; + emit(state.copyWith(isLoadingMore: true)); + final nextPage = state.currentPage + 1; + + final ApiResult result; + final term = state.activeSearchTerm; + final filter = state.activeFilter; + + if (term != null && term.isNotEmpty) { + result = + await _searchReceiverOrdersUseCase.execute(term, page: nextPage); + } else if (filter != null) { + final apiStatus = orderStatusToApi(filter); + if (apiStatus == null) { + if (mySeq == _listRequestSeq) { + emit(state.copyWith(isLoadingMore: false)); + } + return; + } + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + result = await _getReceivesByStatusUseCase.execute( + userId, + apiStatus, + page: nextPage, + ); + } else { + final userId = await _resolveUserId(); + if (mySeq != _listRequestSeq) return; + if (userId == null) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + result = + await _getReceiverOrdersUseCase.execute(userId, page: nextPage); + } + + if (mySeq != _listRequestSeq) return; + _appendPage(result); + } + + Future _loadOrderDetail(int id) async { + emit(state.copyWith(orderDetailResource: Resource.loading())); + final result = await _getReceiverOrderByIdUseCase.execute(id); + + if (result is SuccessApiResult) { + emit(state.copyWith(orderDetailResource: Resource.success(result.data))); + } else if (result is ErrorApiResult) { + emit(state.copyWith(orderDetailResource: Resource.error(result.error))); + } else { + emit(state.copyWith( + orderDetailResource: Resource.error('Unknown error'), + )); + } + } + + Future _confirm(int id, {required bool accept}) async { + emit(state.copyWith(confirmResource: Resource.loading())); + final result = + await _confirmReceiverOrderUseCase.execute(id, accept: accept); + + if (result is SuccessApiResult) { + final current = state.ordersResource.data ?? const []; + final filter = state.activeFilter; + final updated = result.data; + + final updatedList = !accept || (filter != null && updated.status != filter) + ? current.where((o) => o.id != id).toList() + : current.map((o) => o.id == id ? updated : o).toList(); + + emit(state.copyWith( + confirmResource: Resource.success(updated), + ordersResource: Resource.success(updatedList), + orderDetailResource: accept + ? Resource.success(updated) + : Resource.initial(), + )); + } else if (result is ErrorApiResult) { + emit(state.copyWith(confirmResource: Resource.error(result.error))); + } else { + emit(state.copyWith(confirmResource: Resource.error('Unknown error'))); + } + } + + void _emitFirstPage(ApiResult result) { + if (result is SuccessApiResult) { + final page = result.data; + emit(state.copyWith( + ordersResource: Resource.success(page.items), + currentPage: page.pageNumber, + hasMore: !page.isLast, + isLoadingMore: false, + )); + _maybeAutoFillPage(); + } else if (result is ErrorApiResult) { + emit(state.copyWith(ordersResource: Resource.error(result.error))); + } else { + emit(state.copyWith(ordersResource: Resource.error('Unknown error'))); + } + } + + void _appendPage(ApiResult result) { + if (result is SuccessApiResult) { + final page = result.data; + final current = state.ordersResource.data ?? const []; + final existingIds = current.map((o) => o.id).toSet(); + final additions = + page.items.where((o) => !existingIds.contains(o.id)).toList(); + emit(state.copyWith( + ordersResource: Resource.success([...current, ...additions]), + currentPage: page.pageNumber, + hasMore: !page.isLast, + isLoadingMore: false, + )); + _maybeAutoFillPage(); + } else { + emit(state.copyWith(isLoadingMore: false)); + } + } + + + void _maybeAutoFillPage() { + if (!state.hasMore || state.isLoadingMore) return; + final visible = state.ordersResource.data?.length ?? 0; + if (visible >= CreateOrderConstants.ordersPageSize) return; + _loadMore(); + } +} diff --git a/lib/features/orders/presentation/manger/receiver_orders_intent.dart b/lib/features/orders/presentation/manger/receiver_orders_intent.dart new file mode 100644 index 0000000..2294833 --- /dev/null +++ b/lib/features/orders/presentation/manger/receiver_orders_intent.dart @@ -0,0 +1,32 @@ +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; + +sealed class ReceiverOrdersIntent {} + +class LoadMyReceives extends ReceiverOrdersIntent {} + +class LoadMoreReceives extends ReceiverOrdersIntent {} + +class LoadReceivesByStatus extends ReceiverOrdersIntent { + final OrderStatus status; + LoadReceivesByStatus(this.status); +} + +class SearchReceives extends ReceiverOrdersIntent { + final String term; + SearchReceives(this.term); +} + +class LoadReceiverOrderDetail extends ReceiverOrdersIntent { + final int id; + LoadReceiverOrderDetail(this.id); +} + +class ConfirmReceiverOrder extends ReceiverOrdersIntent { + final int id; + final bool accept; + ConfirmReceiverOrder(this.id, {required this.accept}); +} + +class ResetReceiverOrderDetail extends ReceiverOrdersIntent {} + +class ResetReceiverConfirm extends ReceiverOrdersIntent {} diff --git a/lib/features/orders/presentation/manger/receiver_orders_state.dart b/lib/features/orders/presentation/manger/receiver_orders_state.dart new file mode 100644 index 0000000..b854142 --- /dev/null +++ b/lib/features/orders/presentation/manger/receiver_orders_state.dart @@ -0,0 +1,56 @@ +import 'package:wassalha/app/config/base_state/base_state.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; + +class ReceiverOrdersState { + final Resource> ordersResource; + final Resource orderDetailResource; + final Resource confirmResource; + + + final OrderStatus? activeFilter; + + final String? activeSearchTerm; + final int currentPage; + final bool hasMore; + final bool isLoadingMore; + + ReceiverOrdersState({ + Resource>? ordersResource, + Resource? orderDetailResource, + Resource? confirmResource, + this.activeFilter, + this.activeSearchTerm, + this.currentPage = 0, + this.hasMore = true, + this.isLoadingMore = false, + }) : ordersResource = ordersResource ?? Resource.initial(), + orderDetailResource = orderDetailResource ?? Resource.initial(), + confirmResource = confirmResource ?? Resource.initial(); + + ReceiverOrdersState copyWith({ + Resource>? ordersResource, + Resource? orderDetailResource, + Resource? confirmResource, + OrderStatus? activeFilter, + String? activeSearchTerm, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + bool clearActiveFilter = false, + bool clearActiveSearchTerm = false, + }) { + return ReceiverOrdersState( + ordersResource: ordersResource ?? this.ordersResource, + orderDetailResource: orderDetailResource ?? this.orderDetailResource, + confirmResource: confirmResource ?? this.confirmResource, + activeFilter: + clearActiveFilter ? null : (activeFilter ?? this.activeFilter), + activeSearchTerm: clearActiveSearchTerm + ? null + : (activeSearchTerm ?? this.activeSearchTerm), + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} diff --git a/lib/features/orders/presentation/pages/order_details_page.dart b/lib/features/orders/presentation/pages/order_details_page.dart index 9415dcf..af79c42 100644 --- a/lib/features/orders/presentation/pages/order_details_page.dart +++ b/lib/features/orders/presentation/pages/order_details_page.dart @@ -148,6 +148,13 @@ class _DetailsBody extends StatelessWidget { Widget _statusBody(BuildContext context, OrderEntity order) { switch (order.status) { + case OrderStatus.declined: + return DeclinedDetailsBody( + order: order, + isEnglish: isEnglish, + onEdit: () => _startEditing(context, order), + onDelete: () => _confirmDelete(context, order), + ); case OrderStatus.awaitingReceiver: return AwaitingReceiverDetailsBody( order: order, @@ -159,7 +166,7 @@ class _DetailsBody extends StatelessWidget { return WaitingDetailsBody( order: order, isEnglish: isEnglish, - onDelete: () => _confirmDelete(context, order), + onCancel: () => _confirmCancel(context, order), ); case OrderStatus.inTransit: return InTransitDetailsBody( @@ -209,6 +216,39 @@ class _DetailsBody extends StatelessWidget { context.read().doIntent(DeleteOrder(id)); } + Future _confirmCancel(BuildContext context, OrderEntity order) async { + final id = order.id; + if (id == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + isEnglish ? AppConstants.cancelOrder : AppConstants.cancelOrderAR, + ), + content: Text( + isEnglish + ? AppConstants.cancelOrderConfirmSender + : AppConstants.cancelOrderConfirmSenderAR, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(isEnglish ? 'No' : 'لا'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + isEnglish ? 'Yes' : 'نعم', + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + context.read().doIntent(CancelOrder(id)); + } + void _comingSoon(BuildContext context) { CustomSnackBar.info(context, 'Coming soon'); } diff --git a/lib/features/orders/presentation/pages/receiver_order_details_page.dart b/lib/features/orders/presentation/pages/receiver_order_details_page.dart new file mode 100644 index 0000000..2771ce6 --- /dev/null +++ b/lib/features/orders/presentation/pages/receiver_order_details_page.dart @@ -0,0 +1,303 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:wassalha/app/core/app_constants.dart'; +import 'package:wassalha/app/core/router/route_names.dart'; +import 'package:wassalha/app/core/ui_helper/color/colors.dart'; +import 'package:wassalha/app/core/ui_helper/style/font_style.dart'; +import 'package:wassalha/app/core/widgets/custom_snack_bar.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_cubit.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_intent.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_state.dart'; +import 'package:wassalha/features/orders/presentation/util/order_status_strings.dart'; +import 'package:wassalha/features/orders/presentation/widgets/details/status_sections.dart'; +import 'package:wassalha/features/orders/presentation/widgets/order_app_bar.dart'; +import 'package:wassalha/features/orders/presentation/widgets/order_list_item.dart'; + +class ReceiverOrderDetailsPage extends StatefulWidget { + final int orderId; + const ReceiverOrderDetailsPage({super.key, required this.orderId}); + + @override + State createState() => + _ReceiverOrderDetailsPageState(); +} + +class _ReceiverOrderDetailsPageState extends State { + ReceiverOrdersCubit? _cubit; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context + .read() + .doIntent(LoadReceiverOrderDetail(widget.orderId)); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _cubit ??= context.read(); + } + + @override + void dispose() { + _cubit + ?..doIntent(ResetReceiverOrderDetail()) + ..doIntent(ResetReceiverConfirm()); + super.dispose(); + } + + + bool _lastConfirmWasAccept = true; + + void _onConfirmStateChanged( + BuildContext context, + ReceiverOrdersState state, + ) { + final isEnglish = context.locale.languageCode == 'en'; + final confirm = state.confirmResource; + if (confirm.isSuccess) { + CustomSnackBar.success( + context, + _lastConfirmWasAccept + ? (isEnglish + ? AppConstants.orderAccepted + : AppConstants.orderAcceptedAR) + : (isEnglish + ? AppConstants.orderDeclined + : AppConstants.orderDeclinedAR), + ); + if (!_lastConfirmWasAccept && context.mounted) { + context.pop(); + } + } else if (confirm.isError) { + final fallback = isEnglish + ? AppConstants.genericError + : AppConstants.genericErrorAR; + CustomSnackBar.error(context, confirm.error ?? fallback); + } + } + + Future _confirmAction({ + required bool accept, + required OrderEntity order, + }) async { + final id = order.id; + if (id == null) return; + final isEnglish = context.locale.languageCode == 'en'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + accept + ? (isEnglish ? AppConstants.accept : AppConstants.acceptAR) + : (isEnglish ? AppConstants.decline : AppConstants.declineAR), + ), + content: Text( + accept + ? (isEnglish + ? AppConstants.acceptOrderConfirm + : AppConstants.acceptOrderConfirmAR) + : (isEnglish + ? AppConstants.declineOrderConfirm + : AppConstants.declineOrderConfirmAR), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(isEnglish ? 'No' : 'لا'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + isEnglish ? 'Yes' : 'نعم', + style: TextStyle(color: accept ? AppColors.primary : Colors.red), + ), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + _lastConfirmWasAccept = accept; + context.read().doIntent( + ConfirmReceiverOrder(id, accept: accept), + ); + } + + @override + Widget build(BuildContext context) { + final isEnglish = context.locale.languageCode == 'en'; + + return BlocConsumer( + listenWhen: (p, c) => + p.confirmResource.status != c.confirmResource.status || + p.orderDetailResource.status != c.orderDetailResource.status, + listener: (context, state) { + _onConfirmStateChanged(context, state); + final detail = state.orderDetailResource; + if (detail.isError && context.mounted) { + CustomSnackBar.info( + context, + isEnglish ? 'This order is no longer available.' : 'هذا الطلب لم يعد متاحًا.', + ); + context.pop(); + } + }, + builder: (context, state) { + final detail = state.orderDetailResource; + return Scaffold( + appBar: buildOrderAppBar( + context, + _appBarTitleFor(detail.data, isEnglish), + ), + body: SafeArea(child: _body(context, state, isEnglish)), + ); + }, + ); + } + + String _appBarTitleFor(OrderEntity? order, bool isEnglish) { + + if (order == null || + order.status == OrderStatus.declined || + order.status == OrderStatus.unknown) { + return isEnglish + ? AppConstants.orderDetails + : AppConstants.orderDetailsAR; + } + return orderListTitle(order.status, isEnglish: isEnglish); + } + + Widget _body( + BuildContext context, + ReceiverOrdersState state, + bool isEnglish, + ) { + final detail = state.orderDetailResource; + if (detail.isLoading || detail.isInitial) { + return const Center(child: CircularProgressIndicator()); + } + final order = detail.data; + if (detail.isError || order == null) { + return _ErrorView( + message: detail.error ?? + (isEnglish + ? AppConstants.genericError + : AppConstants.genericErrorAR), + onRetry: () => context + .read() + .doIntent(LoadReceiverOrderDetail(widget.orderId)), + ); + } + return _DetailsBody( + order: order, + isEnglish: isEnglish, + isProcessing: state.confirmResource.isLoading, + onAccept: () => _confirmAction(accept: true, order: order), + onDecline: () => _confirmAction(accept: false, order: order), + ); + } +} + +class _DetailsBody extends StatelessWidget { + final OrderEntity order; + final bool isEnglish; + final bool isProcessing; + final VoidCallback onAccept; + final VoidCallback onDecline; + + const _DetailsBody({ + required this.order, + required this.isEnglish, + required this.isProcessing, + required this.onAccept, + required this.onDecline, + }); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: size.width * 0.06), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: size.height * 0.02), + OrderListItem( + order: order, + onTap: () => context.push(RouteNames.orderInfo, extra: order), + ), + _statusBody(order), + SizedBox(height: size.height * 0.02), + ], + ), + ); + } + + Widget _statusBody(OrderEntity order) { + switch (order.status) { + case OrderStatus.awaitingReceiver: + return ReceiverAwaitingDetailsBody( + order: order, + isEnglish: isEnglish, + isProcessing: isProcessing, + onAccept: onAccept, + onDecline: onDecline, + ); + case OrderStatus.waiting: + return WaitingDetailsBody(order: order, isEnglish: isEnglish); + case OrderStatus.inTransit: + return InTransitDetailsBody( + order: order, + isEnglish: isEnglish, + onTrack: () {}, + ); + case OrderStatus.delivered: + return DeliveredDetailsBody(order: order, isEnglish: isEnglish); + case OrderStatus.cancelled: + return CancelledDetailsBody(order: order, isEnglish: isEnglish); + case OrderStatus.declined: + case OrderStatus.unknown: + return const SizedBox.shrink(); + } + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + color: AppColors.textGrey, size: 56), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + message, + textAlign: TextAlign.center, + style: + AppStyles.font14Regular.copyWith(color: AppColors.textGrey), + ), + ), + const SizedBox(height: 16), + TextButton(onPressed: onRetry, child: const Text('Retry')), + ], + ), + ); + } +} diff --git a/lib/features/orders/presentation/pages/your_orders_page.dart b/lib/features/orders/presentation/pages/your_orders_page.dart index 0436807..807adcf 100644 --- a/lib/features/orders/presentation/pages/your_orders_page.dart +++ b/lib/features/orders/presentation/pages/your_orders_page.dart @@ -1,4 +1,4 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -20,6 +20,7 @@ import 'package:wassalha/features/orders/presentation/util/order_status_strings. import 'package:wassalha/features/orders/presentation/widgets/order_app_bar.dart'; import 'package:wassalha/features/orders/presentation/widgets/order_list_item.dart'; import 'package:wassalha/features/orders/presentation/widgets/orders_filter_bar.dart'; +import 'package:wassalha/features/orders/presentation/widgets/orders_list_shimmer.dart'; class YourOrdersPage extends StatefulWidget { const YourOrdersPage({super.key}); @@ -30,24 +31,38 @@ class YourOrdersPage extends StatefulWidget { class _YourOrdersPageState extends State { final _searchController = TextEditingController(); + final _scrollController = ScrollController(); OrderStatus? _filter; @override void initState() { super.initState(); - final cubit = context.read(); - if (cubit.state.ordersResource.isInitial || - cubit.state.ordersResource.isError) { - cubit.doIntent(LoadMyOrders()); - } + + context.read().doIntent(LoadMyOrders()); + _scrollController.addListener(_onScroll); } @override void dispose() { _searchController.dispose(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final remaining = position.maxScrollExtent - position.pixels; + if (remaining > CreateOrderConstants.ordersLoadMoreThreshold) return; + + final cubit = context.read(); + final s = cubit.state; + if (!s.hasMore || s.isLoadingMore) return; + if (!s.ordersResource.isSuccess) return; + cubit.doIntent(LoadMoreOrders()); + } + void _onSearchSubmitted() { context.read().doIntent(SearchOrders(_searchController.text)); } @@ -103,39 +118,63 @@ class _YourOrdersPageState extends State { SizedBox(height: size.height * 0.015), OrdersFilterBar( selected: _filter, - onChanged: (s) => setState(() => _filter = s), + showDeclined: true, + onChanged: (s) { + setState(() => _filter = s); + final cubit = context.read(); + if (s == null) { + cubit.doIntent(LoadMyOrders()); + } else { + cubit.doIntent(LoadOrdersByStatus(s)); + } + }, ), SizedBox(height: size.height * 0.015), Expanded( child: BlocBuilder( - buildWhen: (p, c) => p.ordersResource != c.ordersResource, + buildWhen: (p, c) => + p.ordersResource != c.ordersResource || + p.isLoadingMore != c.isLoadingMore || + p.hasMore != c.hasMore, builder: (context, state) { final resource = state.ordersResource; if (resource.isLoading || resource.isInitial) { - return const Center(child: CircularProgressIndicator()); + return const OrdersListShimmer(); } - final all = resource.data ?? const []; - final filtered = _filter == null - ? all - : all.where((o) => o.status == _filter).toList(); + final items = resource.data ?? const []; - if (filtered.isEmpty) { + if (items.isEmpty) { return _EmptyView(isEnglish: isEnglish); } + final showFooter = state.hasMore || state.isLoadingMore; + final itemCount = items.length + (showFooter ? 1 : 0); + return RefreshIndicator( - onRefresh: () async => context - .read() - .doIntent(LoadMyOrders()), + onRefresh: () async { + final cubit = context.read(); + final f = _filter; + if (f == null) { + cubit.doIntent(LoadMyOrders()); + } else { + cubit.doIntent(LoadOrdersByStatus(f)); + } + }, child: ListView.separated( + controller: _scrollController, padding: EdgeInsets.only(bottom: size.height * 0.08), physics: const AlwaysScrollableScrollPhysics(), - itemCount: filtered.length, + itemCount: itemCount, separatorBuilder: (_, _) => SizedBox(height: size.height * 0.014), itemBuilder: (context, index) { - final order = filtered[index]; + if (index >= items.length) { + return _PaginationFooter( + isLoading: state.isLoadingMore, + ); + } + final order = items[index]; return OrderListItem( order: order, onTap: order.id == null @@ -184,4 +223,18 @@ class _EmptyView extends StatelessWidget { ), ); } -} \ No newline at end of file +} + +class _PaginationFooter extends StatelessWidget { + final bool isLoading; + const _PaginationFooter({required this.isLoading}); + + @override + Widget build(BuildContext context) { + if (!isLoading) return const SizedBox.shrink(); + return const Padding( + padding: EdgeInsets.only(top: 4), + child: OrderCardSkeleton(), + ); + } +} diff --git a/lib/features/orders/presentation/pages/your_receives_page.dart b/lib/features/orders/presentation/pages/your_receives_page.dart new file mode 100644 index 0000000..37d1eca --- /dev/null +++ b/lib/features/orders/presentation/pages/your_receives_page.dart @@ -0,0 +1,218 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:wassalha/app/core/app_constants.dart'; +import 'package:wassalha/app/core/router/route_names.dart'; +import 'package:wassalha/app/core/ui_helper/color/colors.dart'; +import 'package:wassalha/app/core/ui_helper/style/font_style.dart'; +import 'package:wassalha/features/createOrder/create_order_constants.dart'; +import 'package:wassalha/features/orders/domain/model/order_entity.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_cubit.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_intent.dart'; +import 'package:wassalha/features/orders/presentation/manger/receiver_orders_state.dart'; +import 'package:wassalha/features/orders/presentation/widgets/filled_text_field.dart'; +import 'package:wassalha/features/orders/presentation/widgets/order_app_bar.dart'; +import 'package:wassalha/features/orders/presentation/widgets/order_list_item.dart'; +import 'package:wassalha/features/orders/presentation/widgets/orders_filter_bar.dart'; +import 'package:wassalha/features/orders/presentation/widgets/orders_list_shimmer.dart'; + +class YourReceivesPage extends StatefulWidget { + const YourReceivesPage({super.key}); + + @override + State createState() => _YourReceivesPageState(); +} + +class _YourReceivesPageState extends State { + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + OrderStatus? _filter; + + @override + void initState() { + super.initState(); + context.read().doIntent(LoadMyReceives()); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final remaining = position.maxScrollExtent - position.pixels; + if (remaining > CreateOrderConstants.ordersLoadMoreThreshold) return; + + final cubit = context.read(); + final s = cubit.state; + if (!s.hasMore || s.isLoadingMore) return; + if (!s.ordersResource.isSuccess) return; + cubit.doIntent(LoadMoreReceives()); + } + + void _onSearchSubmitted() { + context + .read() + .doIntent(SearchReceives(_searchController.text)); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final isEnglish = context.locale.languageCode == 'en'; + + return Scaffold( + appBar: buildOrderAppBar( + context, + isEnglish ? AppConstants.yourReceives : AppConstants.yourReceivesAR, + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: size.width * 0.06), + child: Column( + children: [ + SizedBox(height: size.height * 0.012), + FilledTextField( + controller: _searchController, + hintText: isEnglish + ? AppConstants.searchYourReceives + : AppConstants.searchYourReceivesAR, + suffix: IconButton( + icon: const Icon(Icons.search, color: AppColors.textGrey), + onPressed: _onSearchSubmitted, + tooltip: isEnglish + ? AppConstants.searchYourReceives + : AppConstants.searchYourReceivesAR, + ), + ), + SizedBox(height: size.height * 0.015), + OrdersFilterBar( + selected: _filter, + onChanged: (s) { + setState(() => _filter = s); + final cubit = context.read(); + if (s == null) { + cubit.doIntent(LoadMyReceives()); + } else { + cubit.doIntent(LoadReceivesByStatus(s)); + } + }, + ), + SizedBox(height: size.height * 0.015), + Expanded( + child: BlocBuilder( + buildWhen: (p, c) => + p.ordersResource != c.ordersResource || + p.isLoadingMore != c.isLoadingMore || + p.hasMore != c.hasMore, + builder: (context, state) { + final resource = state.ordersResource; + if (resource.isLoading || resource.isInitial) { + return const OrdersListShimmer(); + } + + final items = resource.data ?? const []; + + if (items.isEmpty) { + return _EmptyView(isEnglish: isEnglish); + } + + final showFooter = state.hasMore || state.isLoadingMore; + final itemCount = items.length + (showFooter ? 1 : 0); + + return RefreshIndicator( + onRefresh: () async { + final cubit = context.read(); + final f = _filter; + if (f == null) { + cubit.doIntent(LoadMyReceives()); + } else { + cubit.doIntent(LoadReceivesByStatus(f)); + } + }, + child: ListView.separated( + controller: _scrollController, + padding: EdgeInsets.only(bottom: size.height * 0.04), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: itemCount, + separatorBuilder: (_, _) => + SizedBox(height: size.height * 0.014), + itemBuilder: (context, index) { + if (index >= items.length) { + return _PaginationFooter( + isLoading: state.isLoadingMore, + ); + } + final order = items[index]; + return OrderListItem( + order: order, + onTap: order.id == null + ? null + : () => context.push( + RouteNames.receiverOrderDetails, + extra: order.id, + ), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyView extends StatelessWidget { + final bool isEnglish; + const _EmptyView({required this.isEnglish}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.move_to_inbox_outlined, + size: size.width * 0.18, + color: AppColors.textGrey, + ), + SizedBox(height: size.height * 0.02), + Text( + isEnglish + ? AppConstants.noReceivesYet + : AppConstants.noReceivesYetAR, + style: AppStyles.font14Regular.copyWith(color: AppColors.textGrey), + ), + ], + ), + ); + } +} + +class _PaginationFooter extends StatelessWidget { + final bool isLoading; + const _PaginationFooter({required this.isLoading}); + + @override + Widget build(BuildContext context) { + if (!isLoading) return const SizedBox.shrink(); + return const Padding( + padding: EdgeInsets.only(top: 4), + child: OrderCardSkeleton(), + ); + } +} diff --git a/lib/features/orders/presentation/util/order_status_strings.dart b/lib/features/orders/presentation/util/order_status_strings.dart index 4001442..7fb0ff9 100644 --- a/lib/features/orders/presentation/util/order_status_strings.dart +++ b/lib/features/orders/presentation/util/order_status_strings.dart @@ -3,6 +3,10 @@ import 'package:wassalha/features/orders/domain/model/order_entity.dart'; String orderListTitle(OrderStatus? filter, {required bool isEnglish}) { switch (filter) { + case OrderStatus.declined: + return isEnglish + ? AppConstants.declinedTitle + : AppConstants.declinedTitleAR; case OrderStatus.awaitingReceiver: return isEnglish ? AppConstants.awaitingReceiverTitle diff --git a/lib/features/orders/presentation/widgets/details/status_sections.dart b/lib/features/orders/presentation/widgets/details/status_sections.dart index b4a4732..7ca9d75 100644 --- a/lib/features/orders/presentation/widgets/details/status_sections.dart +++ b/lib/features/orders/presentation/widgets/details/status_sections.dart @@ -72,13 +72,14 @@ class AwaitingReceiverDetailsBody extends StatelessWidget { class WaitingDetailsBody extends StatelessWidget { final OrderEntity order; final bool isEnglish; - final VoidCallback? onDelete; + + final VoidCallback? onCancel; const WaitingDetailsBody({ super.key, required this.order, required this.isEnglish, - this.onDelete, + this.onCancel, }); @override @@ -87,14 +88,16 @@ class WaitingDetailsBody extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(height: size.height * 0.02), - AppButton( - text: isEnglish - ? AppConstants.deleteOrder - : AppConstants.deleteOrderAR, - onPressed: onDelete ?? () {}, - backgroundColor: Colors.red, - ), + if (onCancel != null) ...[ + SizedBox(height: size.height * 0.02), + AppButton( + text: isEnglish + ? AppConstants.cancelOrder + : AppConstants.cancelOrderAR, + onPressed: onCancel!, + backgroundColor: Colors.red, + ), + ], SizedBox(height: size.height * 0.025), _StatusFooter( icon: SvgPicture.asset( @@ -345,6 +348,128 @@ class AmountPaidRow extends StatelessWidget { } } +class DeclinedDetailsBody extends StatelessWidget { + final OrderEntity order; + final bool isEnglish; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + const DeclinedDetailsBody({ + super.key, + required this.order, + required this.isEnglish, + this.onEdit, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: size.height * 0.02), + Row( + children: [ + Expanded( + child: AppButton( + text: isEnglish + ? AppConstants.updateAndResubmit + : AppConstants.updateAndResubmitAR, + onPressed: onEdit ?? () {}, + ), + ), + const SizedBox(width: 12), + Expanded( + child: AppButton( + text: isEnglish + ? AppConstants.deleteOrder + : AppConstants.deleteOrderAR, + onPressed: onDelete ?? () {}, + backgroundColor: Colors.red, + ), + ), + ], + ), + SizedBox(height: size.height * 0.025), + _StatusFooter( + icon: const Icon( + Icons.cancel_schedule_send_outlined, + color: Color(0xFFE74C3C), + size: 40, + ), + title: isEnglish + ? AppConstants.declinedTitle + : AppConstants.declinedTitleAR, + label: isEnglish + ? AppConstants.declinedHint + : AppConstants.declinedHintAR, + ), + ], + ); + } +} + +class ReceiverAwaitingDetailsBody extends StatelessWidget { + final OrderEntity order; + final bool isEnglish; + final bool isProcessing; + final VoidCallback? onAccept; + final VoidCallback? onDecline; + + const ReceiverAwaitingDetailsBody({ + super.key, + required this.order, + required this.isEnglish, + this.isProcessing = false, + this.onAccept, + this.onDecline, + }); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: size.height * 0.02), + Row( + children: [ + Expanded( + child: AppButton( + text: isEnglish ? AppConstants.accept : AppConstants.acceptAR, + onPressed: isProcessing ? () {} : (onAccept ?? () {}), + ), + ), + const SizedBox(width: 12), + Expanded( + child: AppButton( + text: isEnglish ? AppConstants.decline : AppConstants.declineAR, + onPressed: isProcessing ? () {} : (onDecline ?? () {}), + backgroundColor: Colors.red, + ), + ), + ], + ), + SizedBox(height: size.height * 0.025), + _StatusFooter( + icon: const Icon( + Icons.mark_email_unread_outlined, + color: AppColors.primary, + size: 40, + ), + title: isEnglish + ? AppConstants.awaitingReceiverTitle + : AppConstants.awaitingReceiverTitleAR, + label: isEnglish + ? AppConstants.awaitingReceiverHint + : AppConstants.awaitingReceiverHintAR, + ), + ], + ); + } +} + class _StatusFooter extends StatelessWidget { final Widget icon; final String? title; diff --git a/lib/features/orders/presentation/widgets/order_status_chip.dart b/lib/features/orders/presentation/widgets/order_status_chip.dart index 6d5c25c..a6d0a18 100644 --- a/lib/features/orders/presentation/widgets/order_status_chip.dart +++ b/lib/features/orders/presentation/widgets/order_status_chip.dart @@ -17,6 +17,14 @@ class OrderStatusChip extends StatelessWidget { late final String label; switch (status) { + case OrderStatus.declined: + + bg = const Color(0xFFFFF4E0); + fg = const Color(0xFFD97706); + label = isEnglish + ? AppConstants.statusDeclined + : AppConstants.statusDeclinedAR; + break; case OrderStatus.inTransit: bg = const Color(0xFFFFF3E0); fg = const Color(0xFFE67E22); @@ -46,13 +54,18 @@ class OrderStatusChip extends StatelessWidget { : AppConstants.statusAwaitingReceiverAR; break; case OrderStatus.waiting: - case OrderStatus.unknown: bg = const Color(0xFFEAF1FF); fg = const Color(0xFF1D5AF4); label = isEnglish ? AppConstants.statusWaiting : AppConstants.statusWaitingAR; break; + case OrderStatus.unknown: + + bg = const Color(0xFFEEEEEE); + fg = const Color(0xFF6B7280); + label = '—'; + break; } return Container( diff --git a/lib/features/orders/presentation/widgets/orders_filter_bar.dart b/lib/features/orders/presentation/widgets/orders_filter_bar.dart index 1a34901..60a2435 100644 --- a/lib/features/orders/presentation/widgets/orders_filter_bar.dart +++ b/lib/features/orders/presentation/widgets/orders_filter_bar.dart @@ -9,10 +9,14 @@ class OrdersFilterBar extends StatelessWidget { final OrderStatus? selected; final ValueChanged onChanged; + + final bool showDeclined; + const OrdersFilterBar({ super.key, required this.selected, required this.onChanged, + this.showDeclined = false, }); @override @@ -30,6 +34,16 @@ class OrdersFilterBar extends StatelessWidget { selected: selected == null, onTap: () => onChanged(null), ), + if (showDeclined) + _chip( + context, + label: isEnglish + ? AppConstants.filterDeclined + : AppConstants.filterDeclinedAR, + icon: Icons.cancel_schedule_send_outlined, + selected: selected == OrderStatus.declined, + onTap: () => onChanged(OrderStatus.declined), + ), _chip( context, label: isEnglish diff --git a/lib/features/orders/presentation/widgets/orders_list_shimmer.dart b/lib/features/orders/presentation/widgets/orders_list_shimmer.dart new file mode 100644 index 0000000..99888cf --- /dev/null +++ b/lib/features/orders/presentation/widgets/orders_list_shimmer.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:wassalha/app/core/ui_helper/color/colors.dart'; +import 'package:wassalha/app/core/widgets/custom_shimmer.dart'; + +class OrdersListShimmer extends StatelessWidget { + final int itemCount; + + const OrdersListShimmer({super.key, this.itemCount = 4}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + separatorBuilder: (_, _) => SizedBox(height: size.height * 0.014), + itemBuilder: (_, _) => const OrderCardSkeleton(), + ); + } +} + +class OrderCardSkeleton extends StatelessWidget { + const OrderCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardBg = isDark ? AppColors.darkSurface : AppColors.lightGrey; + + return Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomShimmer.rectangular( + height: size.height * 0.18, + shapeBorder: const RoundedRectangleBorder(), + ), + Padding( + padding: EdgeInsets.all(size.width * 0.035), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomShimmer.rectangular( + height: 16, + width: size.width * 0.45, + shapeBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + SizedBox(height: size.height * 0.012), + CustomShimmer.rectangular( + height: 14, + width: size.width * 0.6, + shapeBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + SizedBox(height: size.height * 0.01), + Row( + children: [ + CustomShimmer.rectangular( + height: 14, + width: size.width * 0.22, + shapeBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + const Spacer(), + CustomShimmer.rectangular( + height: 14, + width: size.width * 0.22, + shapeBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 8a10e29..99ac595 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -94,7 +94,7 @@ class ProfilePage extends StatelessWidget { ProfileMenuItem( iconPath: 'assets/icons/my receive.png', title: isEnglish ? AppConstants.myReceives : AppConstants.myReceivesAR, - onTap: () {}, + onTap: () => context.push(RouteNames.yourReceives), ), SizedBox(height: size.height * 0.005), ProfileMenuItem( diff --git a/lib/main.dart b/lib/main.dart index fd574ac..05fd0ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,10 @@ import 'package:wassalha/app/core/ui_helper/theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + + PaintingBinding.instance.imageCache.maximumSizeBytes = 256 << 20; + PaintingBinding.instance.imageCache.maximumSize = 2000; + await Firebase.initializeApp(); await EasyLocalization.ensureInitialized(); diff --git a/pubspec.lock b/pubspec.lock index 53fe4c4..ccdb38c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -837,14 +837,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -913,18 +905,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1358,26 +1350,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" timezone: dependency: transitive description: