diff --git a/assets/fonts/DigitalNumbers-Regular.ttf b/assets/fonts/DigitalNumbers-Regular.ttf new file mode 100644 index 0000000..d6017ca Binary files /dev/null and b/assets/fonts/DigitalNumbers-Regular.ttf differ diff --git a/assets/icons/qr_icon_order.png b/assets/icons/qr_icon_order.png new file mode 100644 index 0000000..00e4334 Binary files /dev/null and b/assets/icons/qr_icon_order.png differ diff --git a/assets/icons/scooter_reserved_placemark_fill.png b/assets/icons/scooter_reserved_placemark_fill.png new file mode 100644 index 0000000..92c6074 Binary files /dev/null and b/assets/icons/scooter_reserved_placemark_fill.png differ diff --git a/assets/icons/timer.png b/assets/icons/timer.png new file mode 100644 index 0000000..7efb8ca Binary files /dev/null and b/assets/icons/timer.png differ diff --git a/assets/splash_logo.png b/assets/splash_logo.png new file mode 100644 index 0000000..61ed566 Binary files /dev/null and b/assets/splash_logo.png differ diff --git a/assets/splash_map.png b/assets/splash_map.png new file mode 100644 index 0000000..77840e2 Binary files /dev/null and b/assets/splash_map.png differ diff --git a/lib/data/network/api_service.dart b/lib/data/network/api_service.dart index 1be610c..295b07a 100644 --- a/lib/data/network/api_service.dart +++ b/lib/data/network/api_service.dart @@ -20,6 +20,7 @@ import 'package:path/path.dart'; import 'package:flutter_client_sse/constants/sse_request_type_enum.dart'; import 'package:flutter_client_sse/flutter_client_sse.dart'; +import '../../domain/entities/client_subscription.dart'; import '../../domain/entities/point.dart'; import '../../domain/entities/user_profile.dart'; import '../../domain/entities/payment_card.dart'; @@ -137,7 +138,8 @@ class ApiService { if (avatarId != null && profileData["avatar"] != null) { final String? avatarPath = profileData["avatar"]["path"]; if (avatarPath != null && avatarPath.isNotEmpty) { - avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString(); } + avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString(); + } } dynamic balanceRaw = profileData["balance"]; @@ -394,19 +396,16 @@ class ApiService { final url = "$baseUrl/scooter/$title/code"; try { - final response = await _dio.get( - url, - options: await _getAuthOptions(), - ); + final response = await _dio.get(url, options: await _getAuthOptions()); if (response.statusCode == 200 || response.statusCode == 201) { return Scooter.fromJson(response.data); } - + if (response.statusCode == 404) { throw ScooterNotFoundException(message: "Самокат не найден"); } - + return null; } on DioException catch (e) { if (e.response?.statusCode == 401) throw UnauthorizedException(); @@ -463,7 +462,7 @@ class ApiService { } } - Future> getClientSubscriptions() async { + Future> getClientSubscriptions() async { const url = "$baseUrl/scootersubscription/client"; try { @@ -474,14 +473,7 @@ class ApiService { final List items = responseData['data'] ?? []; return items.map((item) { - final Map subscriptionMap = - Map.from(item['subscription'] ?? {}); - - if (item['expiredAt'] != null) { - subscriptionMap['activeTo'] = item['expiredAt']; - } - - return Subscription.fromJson(subscriptionMap); + return ClientSubscription.fromJson(item); }).toList(); } return []; @@ -547,7 +539,7 @@ class ApiService { } } - Future addPaymentCard({ + Future addPaymentCard({ required String cardNumber, required String cardHolder, required int expirationMonth, @@ -571,8 +563,9 @@ class ApiService { ); if (response.statusCode == 200 || response.statusCode == 201) { - return response.data['id'] as int; + return; } + throw AuthException('Непредвиденный статус: ${response.statusCode}', 0); } on DioException catch (e) { final data = e.response?.data; @@ -674,7 +667,9 @@ class ApiService { final firstError = data['message'][0]['message'].toString(); if (firstError.contains("Wrong start zone")) { - throw WrongZoneException(message: "Некорректная зона для начала поездки."); + throw WrongZoneException( + message: "Некорректная зона для начала поездки.", + ); } } @@ -705,7 +700,9 @@ class ApiService { final firstError = data['message'][0]['message'].toString(); if (firstError.contains("Wrong start zone")) { - throw WrongZoneException(message: "Некорректная зона для начала поездки."); + throw WrongZoneException( + message: "Некорректная зона для начала поездки.", + ); } } @@ -791,7 +788,9 @@ class ApiService { final firstError = data['message'][0]['message'].toString(); if (firstError.contains("Wrong start zone")) { - throw WrongZoneException(message: "Некорректная зона для завершения поездки."); + throw WrongZoneException( + message: "Некорректная зона для завершения поездки.", + ); } } @@ -803,7 +802,7 @@ class ApiService { } } - Future payRide(int orderId) async { + Future payRide(int orderId) async { try { final response = await _dio.put( "$baseUrl/scooterorder/$orderId/pay", @@ -811,12 +810,12 @@ class ApiService { ); if (response.statusCode == 200 || response.statusCode == 201) { - return ScooterOrder.fromJson(response.data); + // return ScooterOrder.fromJson(response.data); + return; } - return null; } on DioException catch (e) { _handleDioError(e); - return null; + return; } } @@ -878,7 +877,7 @@ class ApiService { } } - Future payScooterOrderWithPhotos({ + Future payScooterOrderWithPhotos({ required int orderId, required int? cardId, required bool isBalance, @@ -891,12 +890,10 @@ class ApiService { ); if (response.statusCode == 200 || response.statusCode == 201) { - return ScooterOrder.fromJson(response.data); + return; } - return null; } on DioException catch (e) { _handleDioError(e); - return null; } } @@ -918,7 +915,6 @@ class ApiService { } } - Future> getScooterOrderRouteHistory({required int id}) async { try { final response = await _dio.get( @@ -930,13 +926,19 @@ class ApiService { final String routeString = response.data['route'] ?? '[]'; final List routeList = json.decode(routeString); - return routeList.map((item) => Point( - (item[1] as num).toDouble(), - (item[0] as num).toDouble(), - )).toList(); + return routeList + .map( + (item) => Point( + (item[1] as num).toDouble(), + (item[0] as num).toDouble(), + ), + ) + .toList(); } - throw RouteHistoryNotFoundException(message: "История маршрута не найдена"); + throw RouteHistoryNotFoundException( + message: "История маршрута не найдена", + ); } on DioException catch (e) { if (e.response?.statusCode == 401) throw UnauthorizedException(); if (e.response?.statusCode == 403) throw AuthBlockException(); @@ -1073,7 +1075,9 @@ class ApiService { if (list is List) { return list.cast>(); } else { - throw Exception('Expected a List under "data" but got ${list.runtimeType}'); + throw Exception( + 'Expected a List under "data" but got ${list.runtimeType}', + ); } } else { throw Exception('Expected a List or Map but got ${data.runtimeType}'); diff --git a/lib/data/repositories/payment_repository_impl.dart b/lib/data/repositories/payment_repository_impl.dart index cd53cf0..9b2ac5c 100644 --- a/lib/data/repositories/payment_repository_impl.dart +++ b/lib/data/repositories/payment_repository_impl.dart @@ -40,7 +40,7 @@ class PaymentRepositoryImpl implements PaymentRepository { required String cvv, }) async { try { - final cardId = await apiService.addPaymentCard( + await apiService.addPaymentCard( cardNumber: cardNumber, cardHolder: cardHolder, expirationMonth: int.parse(expiryMonth), @@ -48,8 +48,7 @@ class PaymentRepositoryImpl implements PaymentRepository { cvv: cvv, ); - // Сохраняем полный номер карты локально - await securityService.saveCardFullNumber(cardId, cardNumber); + // await securityService.saveCardFullNumber(cardId, cardNumber); return Success(null); } on AuthException catch (e) { diff --git a/lib/data/repositories/scooter_repository_impl.dart b/lib/data/repositories/scooter_repository_impl.dart index 2ba98e3..e39e751 100644 --- a/lib/data/repositories/scooter_repository_impl.dart +++ b/lib/data/repositories/scooter_repository_impl.dart @@ -10,6 +10,7 @@ import 'package:be_happy/domain/repositories/scooter_repository.dart'; import '../../core/failures.dart'; import '../../core/result.dart'; import '../../domain/entities/active_scooter_order.dart'; +import '../../domain/entities/client_subscription.dart'; import '../../domain/entities/scooter.dart'; import '../../domain/entities/tariff.dart'; import '../../domain/entities/subscription.dart'; @@ -127,8 +128,8 @@ class ScooterRepositoryImpl extends ScooterRepository { } @override - Future>> getClientSubscriptions() async { - late final Result> result; + Future>> getClientSubscriptions() async { + late final Result> result; try { final subscriptions = await _apiService.getClientSubscriptions(); result = Success(subscriptions); @@ -268,15 +269,12 @@ class ScooterRepositoryImpl extends ScooterRepository { } @override - Future> payRide(int orderId) async { - late final Result result; + Future> payRide(int orderId) async { + late final Result result; try { - final order = await _apiService.payRide(orderId); - if (order != null) { - result = Success(order); - } else { - result = Failure(UnknownFailure("Неизвестная ошибка")); - } + await _apiService.payRide(orderId); + result = Success(null); + } on AuthException catch (e) { result = Failure(AuthFailure(e.attemptsLeft)); } catch (e) { @@ -335,23 +333,19 @@ class ScooterRepositoryImpl extends ScooterRepository { } @override - Future> payScooterOrderWithPhotos({ + Future> payScooterOrderWithPhotos({ required int orderId, required int? cardId, required bool isBalance, }) async { - late final Result result; + late final Result result; try { final order = await _apiService.payScooterOrderWithPhotos( orderId: orderId, cardId: cardId, isBalance: isBalance, ); - if (order != null) { - result = Success(order); - } else { - result = Failure(UnknownFailure("Неизвестная ошибка")); - } + result = Success(null); } on AuthException catch (e) { result = Failure(AuthFailure(e.attemptsLeft)); } catch (e) { diff --git a/lib/di/service_locator.dart b/lib/di/service_locator.dart index 77a9d8e..48c9864 100644 --- a/lib/di/service_locator.dart +++ b/lib/di/service_locator.dart @@ -112,7 +112,7 @@ final getIt = GetIt.instance; Future setupDependencies() async { final sharedPreferences = await SharedPreferences.getInstance(); final dio = Dio(); - dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true)); + dio.interceptors.add(LogInterceptor(/*requestHeader: false, responseHeader:false, */responseBody: true, requestBody: true)); dio.interceptors.add(AuthInterceptor()); // HTTP getIt.registerSingleton(http.Client()); @@ -319,6 +319,7 @@ Future setupDependencies() async { getIt(), getIt(), getIt(), + getIt(), ), ); diff --git a/lib/domain/entities/client_subscription.dart b/lib/domain/entities/client_subscription.dart new file mode 100644 index 0000000..f68a51c --- /dev/null +++ b/lib/domain/entities/client_subscription.dart @@ -0,0 +1,24 @@ +import 'package:be_happy/domain/entities/subscription.dart'; + +class ClientSubscription { + final int id; + final int subscriptionId; + final Subscription subscription; + final DateTime? expiredAt; + + ClientSubscription({ + required this.id, + required this.subscriptionId, + required this.subscription, + this.expiredAt, + }); + + factory ClientSubscription.fromJson(Map json) { + return ClientSubscription( + id: json['id'] ?? 0, + subscriptionId: json['subscriptionId'] ?? 0, + subscription: Subscription.fromJson(json['subscription'] as Map), + expiredAt: json['expiredAt'] != null ? DateTime.parse(json['expiredAt']) : null, + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/scooter.dart b/lib/domain/entities/scooter.dart index 3422e00..4168e57 100644 --- a/lib/domain/entities/scooter.dart +++ b/lib/domain/entities/scooter.dart @@ -43,8 +43,9 @@ class Scooter { ); } + @override String toString() { - return 'Scooter{id: $id, title: $title}'; + return 'Scooter{id: $id, title: $title, status: $status, latitude: $latitude, longitude: $longitude, batteryLevel: $batteryLevel, isOnline: $isOnline}'; } } diff --git a/lib/domain/entities/scooter_order.dart b/lib/domain/entities/scooter_order.dart index 85b62c0..9d815ca 100644 --- a/lib/domain/entities/scooter_order.dart +++ b/lib/domain/entities/scooter_order.dart @@ -3,7 +3,7 @@ import 'scooter.dart'; class ScooterOrder { final int id; final int scooterId; - final Scooter? scooter; + final Scooter scooter; final int? planId; final ScooterPlan? plan; final int clientId; @@ -33,7 +33,7 @@ class ScooterOrder { ScooterOrder({ required this.id, required this.scooterId, - this.scooter, + required this.scooter, this.planId, this.plan, required this.clientId, @@ -65,7 +65,7 @@ class ScooterOrder { return ScooterOrder( id: json['id'] ?? 0, scooterId: json['scooterId'] ?? 0, - scooter: json['scooter'] != null ? Scooter.fromJson(json['scooter']) : null, + scooter: Scooter.fromJson(json['scooter']), planId: json['planId'], plan: json['plan'] != null ? ScooterPlan.fromJson(json['plan']) : null, clientId: json['clientId'] ?? 0, diff --git a/lib/domain/entities/subscription.dart b/lib/domain/entities/subscription.dart index 4f6ddba..51f1d69 100644 --- a/lib/domain/entities/subscription.dart +++ b/lib/domain/entities/subscription.dart @@ -8,6 +8,7 @@ class Subscription { final String fullDescription; final int planId; final bool isActive; + final bool isCurrent; final String currency; final DateTime? activeFrom; final DateTime? activeTo; @@ -28,6 +29,7 @@ class Subscription { this.activeTo, required this.createdAt, required this.updatedAt, + required this.isCurrent, required this.options, }); @@ -48,6 +50,7 @@ class Subscription { activeTo: json['activeTo'] != null ? DateTime.parse(json['activeTo']) : null, createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : DateTime.now(), updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : DateTime.now(), + isCurrent: json['isCurrent'] ?? false, options: optionsData.map((e) => SubscriptionPeriod.fromJson(e as Map)).toList(), ); } diff --git a/lib/domain/repositories/scooter_repository.dart b/lib/domain/repositories/scooter_repository.dart index 239a008..1e2be47 100644 --- a/lib/domain/repositories/scooter_repository.dart +++ b/lib/domain/repositories/scooter_repository.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:be_happy/domain/entities/active_scooter_order.dart'; import '../../core/result.dart'; +import '../entities/client_subscription.dart'; import '../entities/point.dart'; import '../entities/scooter.dart'; import '../entities/subscription.dart'; @@ -16,7 +17,7 @@ abstract class ScooterRepository { Future>> getAvailableTariffs(int scooterId); Future>> getAvailableSubscriptions(); Future> getSubscriptionById(int id); - Future>> getClientSubscriptions(); + Future>> getClientSubscriptions(); Future> bookScooter({ required int scooterId, required int planId, @@ -30,13 +31,13 @@ abstract class ScooterRepository { Future> pauseRide(int orderId); Future> resumeRide(int orderId); Future> finishRide(int orderId, List files); - Future> payRide(int orderId); + Future> payRide(int orderId); Future>> getClientOrders(); Future>> uploadScooterPhotos(List images); Future> updateScooterOrderData({ required int orderId, }); - Future> payScooterOrderWithPhotos({ + Future> payScooterOrderWithPhotos({ required int orderId, required int? cardId, required bool isBalance, diff --git a/lib/domain/usecase/get_client_subscriptions_usecase.dart b/lib/domain/usecase/get_client_subscriptions_usecase.dart index 0155332..d1b0be3 100644 --- a/lib/domain/usecase/get_client_subscriptions_usecase.dart +++ b/lib/domain/usecase/get_client_subscriptions_usecase.dart @@ -1,6 +1,7 @@ import 'package:be_happy/core/result.dart'; import 'package:be_happy/domain/entities/scooter_order.dart'; +import '../entities/client_subscription.dart'; import '../repositories/scooter_repository.dart'; @@ -14,7 +15,7 @@ class GetClientSubscriptionsUsecase { GetClientSubscriptionsUsecase(this.repository); - Future>> call() { + Future>> call() { return repository.getClientSubscriptions(); } } diff --git a/lib/domain/usecase/pay_ride_usecase.dart b/lib/domain/usecase/pay_ride_usecase.dart index a75b5b3..034cefe 100644 --- a/lib/domain/usecase/pay_ride_usecase.dart +++ b/lib/domain/usecase/pay_ride_usecase.dart @@ -7,7 +7,7 @@ class PayRideUsecase { PayRideUsecase(this.repository); - Future> call(int orderId, int? cardId, + Future> call(int orderId, int? cardId, bool isBalance) { return repository.payScooterOrderWithPhotos(orderId: orderId, cardId: cardId, isBalance: isBalance); } diff --git a/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart b/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart index 8d51b42..a35c40b 100644 --- a/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart +++ b/lib/domain/usecase/pay_scooter_order_with_photos_usecase.dart @@ -7,7 +7,7 @@ class PayScooterOrderWithPhotosUsecase { PayScooterOrderWithPhotosUsecase(this.repository); - Future> call({ + Future> call({ required int orderId, required int cardId, required bool isBalance, diff --git a/lib/main.dart b/lib/main.dart index 7795097..5dc56a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,9 +23,21 @@ import 'di/service_locator.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top], + ); + + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ); await setupDependencies(); + runApp(const MyApp()); } @@ -62,4 +74,4 @@ class MyApp extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/components/map_settings_sheet.dart b/lib/presentation/components/map_settings_sheet.dart deleted file mode 100644 index c04c4dc..0000000 --- a/lib/presentation/components/map_settings_sheet.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'dart:ui'; -import 'package:be_happy/presentation/event/map_event.dart'; -import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; -import 'package:be_happy/presentation/state/map_settings_modal_state.dart'; -import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -import '../viewmodel/map_bloc.dart'; - -class MapSettingsSheet extends StatelessWidget { - final VoidCallback? onClose; - - const MapSettingsSheet({super.key, this.onClose}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final List<_SettingItemData> items = [ - _SettingItemData( - label: 'Геоточки', - icon: Icons.location_on_outlined, - color: const Color(0xFF66E3C4), - isActive: state.isAllGeomarksActive, - onChanged: (val) => context.read().add(AllGeomarksToggled(val)), - ), - _SettingItemData( - label: 'Геозоны', - icon: Icons.gps_fixed_outlined, - color: const Color(0xFF86EFAC), - isActive: state.isAllGeozonesActive, - onChanged: (val) => context.read().add(AllGeozonesToggled(val)), - ), - _SettingItemData( - label: 'Парковка', - icon: Icons.home_outlined, - color: const Color(0xFFA78BFA), - isActive: state.isParkingZoneActive, - onChanged: (val) => context.read().add(ParkingZonesToggled(val)), - ), - _SettingItemData( - label: 'Парковка запрещена', - icon: Icons.block_outlined, - color: const Color(0xFFF59E0B), - isActive: state.isRestrictedParkingZoneActive, - onChanged: (val) => context.read().add(RestrictedParkingZonesToggled(val)), - ), - _SettingItemData( - label: 'Запрещено кататься', - icon: Icons.warning_amber_outlined, - color: const Color(0xFFEF4444), - isActive: state.isRestrictedDrivingZoneActive, - onChanged: (val) => context.read().add(RestrictedDrivingZonesToggled(val)), - ), - ]; - - return Align( - alignment: Alignment.bottomCenter, - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - height: 365, - decoration: BoxDecoration( - color: const Color(0xFF000032).withOpacity(0.88), - borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Параметры карты', - style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), - ), - TextButton( - onPressed: () { - context.read().add(ApllyButtonClick()); - context.read().add(UpdateMap()); - Navigator.of(context).pop(); - }, - child: const Text( - 'Готово', - style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600), - ), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 10), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - leading: Icon(item.icon, color: item.color), - title: Text(item.label, style: const TextStyle(color: Colors.white)), - trailing: Switch.adaptive( - value: item.isActive, - onChanged: item.onChanged, - activeTrackColor: const Color(0xFF66E3C4), - inactiveThumbColor: Colors.white, - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - } -} - -// Вспомогательный класс для описания строк -class _SettingItemData { - final String label; - final IconData icon; - final Color color; - final bool isActive; - final ValueChanged onChanged; - - _SettingItemData({ - required this.label, - required this.icon, - required this.color, - required this.isActive, - required this.onChanged, - }); -} diff --git a/lib/presentation/components/sheet/active_ride_sheet.dart b/lib/presentation/components/sheet/active_ride_sheet.dart index a564221..2be73e1 100644 --- a/lib/presentation/components/sheet/active_ride_sheet.dart +++ b/lib/presentation/components/sheet/active_ride_sheet.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'dart:ui'; +import 'package:be_happy/domain/entities/scooter.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../di/service_locator.dart'; import '../../event/active_ride_event.dart'; +import '../../event/map_event.dart'; import '../../state/active_ride_state.dart'; import '../../viewmodel/active_ride_bloc.dart'; +import '../../viewmodel/map_bloc.dart'; import '../dialog/finish_ride_confirmation_dialog.dart'; import '../notification_toast.dart'; @@ -56,7 +59,7 @@ class _ActiveRideSheetState extends State { return BlocProvider.value( value: _bloc, child: BlocConsumer( - listenWhen: (previous, current) => previous.inZone != current.inZone, + listenWhen: (previous, current) => previous.inZone != current.inZone || previous.status != current.status, listener: (context, state) { if (!state.inZone) { BotToast.showCustomNotification( @@ -71,6 +74,15 @@ class _ActiveRideSheetState extends State { }, ); } + + if (state.status == ActiveRideStatus.success && state.order != null) { + final scooter = state.order!.scooter; + context.read().add(FocusOnScooter(Scooter(id: scooter.id, + title: scooter.title, status: scooter.status, + latitude: state.longitude, longitude: state.latitude, + batteryLevel: scooter.batteryLevel, isOnline: scooter.isOnline, + maxSpeed: scooter.maxSpeed, number: scooter.number))); + } }, builder: (context, state) { // Логика отображения загрузки и ошибок остается прежней @@ -203,8 +215,8 @@ class _ActiveRideSheetState extends State { color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold, - fontFeatures: [FontFeature.tabularFigures()], - fontFamily: 'Digital Numbers', + fontFeatures: const [FontFeature.tabularFigures()], + fontFamily: 'DigitalNumbers', ), ), const SizedBox(height: 4), @@ -306,10 +318,27 @@ class _ActiveRideSheetState extends State { } // 🔹 Если отменил — ничего не делаем, диалог уже закрылся }, + borderRadius: BorderRadius.circular(16), + child: Column( // ✅ Вернули Column с иконкой и текстом + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.stop, + color: Colors.white, + size: 24, + ), + const SizedBox(height: 4), + const Text( + 'ЗАВЕРШИТЬ', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), - - - ), ), ), diff --git a/lib/presentation/components/sheet/current_rides_sheet.dart b/lib/presentation/components/sheet/current_rides_sheet.dart index d0bd767..5305cea 100644 --- a/lib/presentation/components/sheet/current_rides_sheet.dart +++ b/lib/presentation/components/sheet/current_rides_sheet.dart @@ -219,11 +219,11 @@ class _RideCardState extends State<_RideCard> { displayTime = _elapsedTime; } final timeString = _formatDuration(displayTime); - final statusText = _getStatusText(widget.order.status); - final statusColor = _getStatusColor(widget.order.status); + final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный"; + final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa); final scooterNumber = - widget.order.scooter?.number ?? widget.order.scooterId.toString(); + widget.order.scooter.number ?? widget.order.scooterId.toString(); return Container( padding: const EdgeInsets.all(16), @@ -261,7 +261,7 @@ class _RideCardState extends State<_RideCard> { Text( statusText, style: TextStyle( - color: Colors.white, + color: statusColor, fontSize: 14, fontWeight: FontWeight.w600, ), @@ -269,22 +269,51 @@ class _RideCardState extends State<_RideCard> { ], ), const SizedBox(height: 4), - Text( - scooterNumber, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - _getLocationText(), - style: TextStyle( - color: Colors.white.withOpacity(0.6), - fontSize: 13, - ), + Row( + children: [ + Image.asset("assets/icons/qr_icon_order.png"), + const SizedBox(width: 6), + Text( + scooterNumber, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], ), + + if (isReserved) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + "Тариф", + style: TextStyle( + color: Color(0xFF8bffaa), + fontSize: 11, + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset("assets/icons/timer.png", width: 14,), + const SizedBox(width: 4), + Text( + widget.order.plan?.title ?? "Название тарифа", + style: TextStyle( + color: Color(0xFF8bffaa), + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ) + ], + ) ], ), ), @@ -301,10 +330,10 @@ class _RideCardState extends State<_RideCard> { timeString, style: TextStyle( color: statusColor, - fontSize: 26, + fontSize: 20, fontWeight: FontWeight.bold, fontFeatures: const [FontFeature.tabularFigures()], - fontFamily: 'Digital Numbers', + fontFamily: 'DigitalNumbers', ), ), ], @@ -360,7 +389,7 @@ class _RideCardState extends State<_RideCard> { switch (status.toLowerCase()) { case 'reserved': case 'holding': - return 'Забронировано'; + return 'Забронирован'; case 'active': case 'in_progress': return 'Активно'; diff --git a/lib/presentation/components/sheet/map_settings_sheet.dart b/lib/presentation/components/sheet/map_settings_sheet.dart index 07b8a2e..27b2f5c 100644 --- a/lib/presentation/components/sheet/map_settings_sheet.dart +++ b/lib/presentation/components/sheet/map_settings_sheet.dart @@ -5,8 +5,6 @@ import 'package:be_happy/presentation/state/map_settings_modal_state.dart'; import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - import '../../viewmodel/map_bloc.dart'; class MapSettingsSheet extends StatelessWidget { @@ -19,13 +17,6 @@ class MapSettingsSheet extends StatelessWidget { return BlocBuilder( builder: (context, state) { final List<_SettingItemData> items = [ - _SettingItemData( - label: 'Геоточки', - icon: Icons.location_on_outlined, - color: const Color(0xFF66E3C4), - isActive: state.isAllGeomarksActive, - onChanged: (val) => context.read().add(AllGeomarksToggled(val)), - ), _SettingItemData( label: 'Геозоны', icon: Icons.gps_fixed_outlined, @@ -41,9 +32,9 @@ class MapSettingsSheet extends StatelessWidget { onChanged: (val) => context.read().add(ParkingZonesToggled(val)), ), _SettingItemData( - label: 'Разрешено кататься', + label: 'Парковка запрещена', icon: Icons.block_outlined, - color: const Color(0xFF5ECD4C), + color: const Color(0xFFF59E0B), isActive: state.isRestrictedParkingZoneActive, onChanged: (val) => context.read().add(RestrictedParkingZonesToggled(val)), ), diff --git a/lib/presentation/components/sheet/payment_method_sheet.dart b/lib/presentation/components/sheet/payment_method_sheet.dart index 4eb5648..f30365d 100644 --- a/lib/presentation/components/sheet/payment_method_sheet.dart +++ b/lib/presentation/components/sheet/payment_method_sheet.dart @@ -12,11 +12,13 @@ import '../../state/payment_method_sheet_state.dart'; import '../../viewmodel/payment_method_sheet_bloc.dart'; class PaymentMethodSheet extends StatefulWidget { - final PaymentCard? initialSelectedCard; // Добавляем это поле + final PaymentCard? initialSelectedCard; + final bool showBalance; const PaymentMethodSheet({ super.key, - this.initialSelectedCard, // Инициализируем в конструкторе + this.initialSelectedCard, + this.showBalance = true, }); @override @@ -92,7 +94,7 @@ class _PaymentMethodSheetState extends State { _selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1; } else { final mainCardIndex = state.cards.indexWhere((card) => card.isMain); - _selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1; + _selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : (widget.showBalance ? -1 : 0); } } @@ -169,19 +171,20 @@ class _PaymentMethodSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( children: [ - PaymentOption( - title: 'Баланс', - subtitle: '${state.balance.toStringAsFixed(2)} BYN', - isSelected: _selectedPaymentMethod == -1, - onTap: () { - setState(() { - _selectedPaymentMethod = -1; - }); - Navigator.pop(context, 'balance'); - }, - ), - - const SizedBox(height: 12), + if (widget.showBalance) ...[ + PaymentOption( + title: 'Баланс', + subtitle: '${state.balance.toStringAsFixed(2)} BYN', + isSelected: _selectedPaymentMethod == -1, + onTap: () { + setState(() { + _selectedPaymentMethod = -1; + }); + Navigator.pop(context, 'balance'); + }, + ), + const SizedBox(height: 12), + ], ...state.cards.asMap().entries.map((entry) { final index = entry.key; diff --git a/lib/presentation/components/sheet/reserved_ride_sheet.dart b/lib/presentation/components/sheet/reserved_ride_sheet.dart index d122c0b..06c9d2f 100644 --- a/lib/presentation/components/sheet/reserved_ride_sheet.dart +++ b/lib/presentation/components/sheet/reserved_ride_sheet.dart @@ -14,7 +14,7 @@ class ReservedRideSheet extends StatefulWidget { final String scooterNumber; final int orderId; final Duration initialReservationTime; - + const ReservedRideSheet({ super.key, required this.scooterNumber, @@ -60,264 +60,286 @@ class _ReservedRideSheetState extends State { Widget build(BuildContext context) { return BlocProvider.value( value: _bloc, - child: Align( - alignment: Alignment.bottomCenter, - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - padding: const EdgeInsets.only(top: 20, bottom: 10), - decoration: BoxDecoration( - color: const Color(0xFF000032).withOpacity(0.5), - borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // HEADER - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Row( - children: [ - Icon( - Icons.arrow_back_ios_sharp, - color: const Color(0x99FFFFFF), - size: 20, - ), - Icon( - Icons.arrow_back_ios_sharp, - color: const Color(0x66FFFFFF), - size: 20, - ), - Icon( - Icons.arrow_back_ios_sharp, - color: const Color(0x22FFFFFF), - size: 20, - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Бесплатное бронирование', - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + child: Scaffold( + backgroundColor: Colors.transparent, + resizeToAvoidBottomInset: false, + body: Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.only(top: 20, bottom: 10), + decoration: BoxDecoration( + color: const Color(0xFF000032).withOpacity(0.5), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(30), ), - - const SizedBox(height: 20), - - // ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - // Таймер - Expanded( - flex: 2, - child: Text( - _formatDuration(_reservationTime), - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.bold, - fontFeatures: [FontFeature.tabularFigures()], - fontFamily: 'Digital Numbers', - ), - ), - ), - // Иконка и информация (ВЫСОКИЙ БЛОК) - Expanded( - flex: 3, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // HEADER + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), child: Row( children: [ - // Иконка самоката (ВЫШЕ) - SizedBox( - width: 44, - height: 56, - child: Image.asset( - 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', - fit: BoxFit.contain, - ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x99FFFFFF), + size: 20, ), - const SizedBox(width: 12), - // Инфо - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Color(0xFFFFB800), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - const Text( - 'Забронирован', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - const SizedBox(height: 8), - Text( - '№${widget.scooterNumber}', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ], - ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x66FFFFFF), + size: 20, + ), + Icon( + Icons.arrow_back_ios_sharp, + color: const Color(0x22FFFFFF), + size: 20, ), ], ), ), - ), - ], - ), - ), - - const SizedBox(height: 16), - - // КНОПКА "НАЧАТЬ ПОЕЗДКУ" - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: BlocListener( - listener: (context, state) { - if (state.rideStarted) { - Navigator.pop(context); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => ActiveRideSheet( - scooterNumber: widget.scooterNumber, - initialElapsedTime: Duration.zero, - orderId: widget.orderId, + const SizedBox(width: 12), + Expanded( + child: Text( + 'Бесплатное бронирование', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, ), - ); - } else if (state.status == ReservedRideStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), - ); - } - }, - child: GradientButton( - text: 'Начать поездку', - showArrows: true, - height: 48, - width: double.infinity, - fontSize: 15, - onTap: () { - _bloc.add(StartRide(widget.orderId)); - }, + ), + ], ), ), - ), - const SizedBox(height: 12), + const SizedBox(height: 20), - // КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ" - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: BlocListener( - listener: (context, state) { - if (state.rideCancelled) { - Navigator.pop(context); - } else if (state.status == ReservedRideStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), - ); - } - }, - child: Container( - height: 48, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Colors.white.withOpacity(0.4), - width: 1, + // ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + // Таймер + Expanded( + flex: 2, + child: Text( + _formatDuration(_reservationTime), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + fontFamily: 'Digital Numbers', + ), + ), ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - final result = await showDialog( - context: context, - builder: (context) => const CancelBookingDialog(), - ); - if (result != null && result) { - _bloc.add(CancelRide(widget.orderId)); - } - }, - borderRadius: BorderRadius.circular(24), - child: BlocBuilder( - builder: (context, state) { - if (state.status == ReservedRideStatus.loading) { - return const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ), - ); - } - return const Center( - child: Text( - 'Отменить бронирование', - style: TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, + // Иконка и информация (ВЫСОКИЙ БЛОК) + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 16, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Иконка самоката (ВЫШЕ) + SizedBox( + width: 44, + height: 56, + child: Image.asset( + 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png', + fit: BoxFit.contain, ), ), + const SizedBox(width: 12), + // Инфо + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFFFB800), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Забронирован', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + const SizedBox(height: 8), + Text( + '№${widget.scooterNumber}', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // КНОПКА "НАЧАТЬ ПОЕЗДКУ" + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocListener( + listener: (context, state) { + if (state.rideStarted) { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ActiveRideSheet( + scooterNumber: widget.scooterNumber, + initialElapsedTime: Duration.zero, + orderId: widget.orderId, + ), + ); + } else if (state.status == + ReservedRideStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Ошибка'), + ), + ); + } + }, + child: GradientButton( + text: 'Начать поездку', + showArrows: true, + height: 48, + width: double.infinity, + fontSize: 15, + onTap: () { + _bloc.add(StartRide(widget.orderId)); + }, + ), + ), + ), + + const SizedBox(height: 12), + + // КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ" + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocListener( + listener: (context, state) { + if (state.rideCancelled) { + Navigator.pop(context); + } else if (state.status == + ReservedRideStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Ошибка'), + ), + ); + } + }, + child: Container( + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + final result = await showDialog( + context: context, + builder: (context) => + const CancelBookingDialog(), ); + if (result != null && result) { + _bloc.add(CancelRide(widget.orderId)); + } }, + borderRadius: BorderRadius.circular(24), + child: + BlocBuilder< + ReservedRideBloc, + ReservedRideState + >( + builder: (context, state) { + if (state.status == + ReservedRideStatus.loading) { + return const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ); + } + return const Center( + child: Text( + 'Отменить бронирование', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), ), ), ), ), ), - ), - const SizedBox(height: 20), - ], + const SizedBox(height: 20), + ], + ), ), ), ), diff --git a/lib/presentation/components/subscription_card.dart b/lib/presentation/components/subscription_card.dart index 1c086ed..3d55322 100644 --- a/lib/presentation/components/subscription_card.dart +++ b/lib/presentation/components/subscription_card.dart @@ -1,13 +1,18 @@ +import 'package:be_happy/presentation/components/gradient_button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../domain/entities/subscription.dart'; +import '../event/subscription_list_event.dart'; class SubscriptionCard extends StatelessWidget { final Subscription subscription; final bool isActive; + final DateTime? expiredAt; + final VoidCallback? onRefresh; - const SubscriptionCard({super.key, required this.subscription, required this.isActive}); + const SubscriptionCard({super.key, required this.subscription, required this.isActive, this.expiredAt, this.onRefresh}); @override Widget build(BuildContext context) { @@ -15,9 +20,9 @@ class SubscriptionCard extends StatelessWidget { ? subscription.options.reduce((a, b) => a.price < b.price ? a : b) : null; - final maxDaysOption = subscription.options.isNotEmpty + /*final maxDaysOption = subscription.options.isNotEmpty ? subscription.options.reduce((a, b) => a.days > b.days ? a : b) - : null; + : null;*/ return Container( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), @@ -43,7 +48,7 @@ class SubscriptionCard extends StatelessWidget { padding: EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), // Опционально: скругление углов + borderRadius: BorderRadius.circular(12), ), child: Text( "АКТИВНА", @@ -64,12 +69,19 @@ class SubscriptionCard extends StatelessWidget { subscription.shortDescription, style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14), ), - const SizedBox(height: 16), - if (maxDaysOption != null) ...[ + if (isActive && expiredAt != null) ...[ const SizedBox(height: 16), - Text( - "Период действия: до ${maxDaysOption.days} дней", - style: const TextStyle(color: Colors.white, fontSize: 14), + Builder( + builder: (context) { + final day = expiredAt!.day.toString().padLeft(2, '0'); + final month = expiredAt!.month.toString().padLeft(2, '0'); + final year = expiredAt!.year; + + return Text( + "Период действия: до $day.$month.$year", + style: const TextStyle(color: Colors.white, fontSize: 14), + ); + }, ), ], const SizedBox(height: 20), @@ -83,15 +95,17 @@ class SubscriptionCard extends StatelessWidget { ) else const SizedBox.shrink(), - ElevatedButton( - onPressed: () => context.push("/home/subscriptions/${subscription.id}"), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF80FFD1), - foregroundColor: Colors.black, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: const Text("Подробнее", style: TextStyle(fontWeight: FontWeight.bold)), + GradientButton( + onTap: () async { + final isSubscribed = await context.push("/home/subscriptions/${subscription.id}"); + if (isSubscribed == true && onRefresh != null) { + onRefresh!(); + } + }, + text: "Подробнее", + enabled: true, + width: 120, + height: 40, ), ], ), diff --git a/lib/presentation/event/map_event.dart b/lib/presentation/event/map_event.dart index 1d4c21e..16886ef 100644 --- a/lib/presentation/event/map_event.dart +++ b/lib/presentation/event/map_event.dart @@ -30,4 +30,14 @@ class NotificationReceived extends ScooterEvent { NotificationReceived(this.notification); } +class FocusOnScooter extends ScooterEvent { + final Scooter scooter; + FocusOnScooter(this.scooter); +} + +class ClearMapPlacemarks extends ScooterEvent {} +class ClearMapFocus extends ScooterEvent {} + + + diff --git a/lib/presentation/navigation/app_router.dart b/lib/presentation/navigation/app_router.dart index 95799f7..7b994bc 100644 --- a/lib/presentation/navigation/app_router.dart +++ b/lib/presentation/navigation/app_router.dart @@ -58,9 +58,9 @@ import '../../domain/usecase/remove_payment_card_usecase.dart'; import '../../domain/usecase/save_map_settings_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../../domain/usecase/verify_pin_usecase.dart'; -import '../components/map_settings_sheet.dart'; import '../components/scooter_bottom_sheet.dart'; import '../components/sheet/current_rides_sheet.dart'; +import '../components/sheet/map_settings_sheet.dart'; import '../components/sheet/payment_method_sheet.dart'; import '../components/sheet/reserved_ride_sheet.dart'; import '../components/sheet/tariff_sheet.dart'; @@ -372,6 +372,7 @@ class AppRouter { SubscriptionDetailsBloc( getIt(), getIt(), + getIt(), ) ..add( LoadDetailsEvent( diff --git a/lib/presentation/screens/add_card_screen.dart b/lib/presentation/screens/add_card_screen.dart index 27381db..d3cd555 100644 --- a/lib/presentation/screens/add_card_screen.dart +++ b/lib/presentation/screens/add_card_screen.dart @@ -19,11 +19,14 @@ class AddCardScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: BlocListener( - listenWhen: (previous, current) => - previous.status != current.status && - current.status == AddCardStatus.success, + listenWhen: (previous, current) { + print( + 'Смена статуса: ${previous.status} -> ${current.status} ${current.errorMessage}'); + return previous.status != current.status && + current.status == AddCardStatus.success; + }, listener: (context, state) { - context.pop(); + context.pop(true); }, child: Container( decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), @@ -149,10 +152,7 @@ class AddCardScreen extends StatelessWidget { child: InkWell( onTap: state.isFormValid ? () => { - context.read().add( - AddCardSubmitted()), - context.read()..add(PaymentMethodsStarted()), - context.pop() + context.read().add(AddCardSubmitted()), } : null, borderRadius: BorderRadius.circular( diff --git a/lib/presentation/screens/documents_screen.dart b/lib/presentation/screens/documents_screen.dart index 49f5e8a..9e71c22 100644 --- a/lib/presentation/screens/documents_screen.dart +++ b/lib/presentation/screens/documents_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/app_colors.dart'; import '../components/custom_app_bar.dart'; @@ -17,12 +18,10 @@ class DocumentsScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( children: [ - // ✅ Используем общий AppBar const SizedBox(height: 16), CustomAppBar(title: 'Документы'), const SizedBox(height: 32), - // Список ссылок LinkRow( icon: 'assets/icons/doc.png', title: 'Договор аренды', @@ -33,14 +32,14 @@ class DocumentsScreen extends StatelessWidget { LinkRow( icon: 'assets/icons/doc.png', title: 'Политика конфиденциальности', - onTap: () => openLink('https://...'), + onTap: () => context.push('/privacy-policy') ), const Divider(height: 1, color: Colors.white24), const SizedBox(height: 12), LinkRow( icon: 'assets/icons/doc.png', title: 'Правила вождения', - onTap: () => openLink('https://...'), + onTap: () => openLink('https://behappybel.by/#rule'), ), const Divider(height: 1, color: Colors.white24), const SizedBox(height: 12), diff --git a/lib/presentation/screens/map_screen.dart b/lib/presentation/screens/map_screen.dart index 01a99d4..a6956ac 100644 --- a/lib/presentation/screens/map_screen.dart +++ b/lib/presentation/screens/map_screen.dart @@ -93,7 +93,9 @@ class _MapScreenState extends State { listenWhen: (previous, current) { return current.lastNotification != previous.lastNotification || - current.flags != previous.flags; + current.flags != previous.flags || + previous.selectedScooterForFocus?.id + != current.selectedScooterForFocus?.id; }, listener: (context, state) { @@ -164,14 +166,39 @@ class _MapScreenState extends State { ); } } + + if (state.selectedScooterForFocus != null) { + final targetScooter = state.selectedScooterForFocus!; + + print("RESERVED SCOOTER: $targetScooter"); + + _moveCameraToPoint( + targetScooter.longitude, + targetScooter.latitude, + zoom: 17, + ); + + context.read().add(ClearMapFocus()); + } }, - buildWhen: (previous, current) => - previous.scooters != current.scooters || - previous.zones != current.zones, + buildWhen: (previous, current) { + return previous.scooters != current.scooters || + previous.reservedScooters != current.reservedScooters || + previous.zones != current.zones || + previous.status != current.status; + }, + builder: (context, state) { - final scooters = _buildScooterPlacemarks( - state.scooters, - state.address ?? "Unknown address", + final freeScooters = _buildScooterPlacemarks( + scooters: state.scooters, + iconAsset: 'assets/icons/scooter_placemark_fill.png', + isClickable: true, + ); + + final reservedScooters = _buildScooterPlacemarks( + scooters: state.reservedScooters ?? [], + iconAsset: 'assets/icons/scooter_reserved_placemark_fill.png', + isClickable: false, ); final zonePolygons = _buildZonePolygons(state.zones); @@ -193,9 +220,10 @@ class _MapScreenState extends State { }, mapObjects: [ ...zonePolygons, + ...reservedScooters, ClusterizedPlacemarkCollection( mapId: const MapObjectId('scooters_cluster'), - placemarks: scooters, + placemarks: freeScooters, radius: 30, minZoom: 15, consumeTapEvents: true, @@ -232,7 +260,6 @@ class _MapScreenState extends State { }, ), - // Индикатор загрузки (отдельный строитель для статуса) BlocBuilder( buildWhen: (previous, current) => previous.status != current.status, @@ -378,6 +405,7 @@ class _MapScreenState extends State { } void _onMarkerTap(List scooters) async { + context.read().add(CheckUser()); final flags = context.read().state.flags; if (!flags.hasCard) { @@ -553,26 +581,26 @@ class _MapScreenState extends State { await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png'); } - List _buildScooterPlacemarks( - List scooters, - String address, - ) { + List _buildScooterPlacemarks({ + required List scooters, + required String iconAsset, + required bool isClickable, + }) { return scooters.map((scooter) { return PlacemarkMapObject( - mapId: MapObjectId('${scooter.id}'), + mapId: MapObjectId('${isClickable ? "" : "reserved_"}${scooter.id}'), // уникальный ID для карты point: Point(latitude: scooter.longitude, longitude: scooter.latitude), icon: PlacemarkIcon.single( PlacemarkIconStyle( - image: BitmapDescriptor.fromAssetImage( - 'assets/icons/scooter_placemark_fill.png', - ), + image: BitmapDescriptor.fromAssetImage(iconAsset), scale: 0.2, ), ), opacity: 1.0, - onTap: (object, point) async => { - _onMarkerTap([scooter]), - }, + consumeTapEvents: isClickable, + onTap: isClickable + ? (object, point) async => _onMarkerTap([scooter]) + : null, ); }).toList(); } diff --git a/lib/presentation/screens/payment_confirm_screen.dart b/lib/presentation/screens/payment_confirm_screen.dart index 4eb8dbb..b8aafa1 100644 --- a/lib/presentation/screens/payment_confirm_screen.dart +++ b/lib/presentation/screens/payment_confirm_screen.dart @@ -9,9 +9,11 @@ import '../components/custom_app_bar.dart'; import '../components/gradient_button.dart'; import '../components/payment_option.dart'; import '../components/sheet/payment_method_sheet.dart'; +import '../event/map_event.dart'; import '../event/payment_confirm_event.dart'; import '../event/payment_method_sheet_event.dart'; import '../state/payment_confirm_state.dart'; +import '../viewmodel/map_bloc.dart'; import '../viewmodel/payment_confirm_bloc.dart'; import '../viewmodel/payment_method_sheet_bloc.dart'; @@ -78,6 +80,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { listener: (context, state) { if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) { + context.read().add(ClearMapPlacemarks()); context.go('/home'); } else if (state.status == PaymentConfirmStatus.failure) { ScaffoldMessenger.of(context).hideCurrentSnackBar(); diff --git a/lib/presentation/screens/payment_methods_screen.dart b/lib/presentation/screens/payment_methods_screen.dart index e231908..f206781 100644 --- a/lib/presentation/screens/payment_methods_screen.dart +++ b/lib/presentation/screens/payment_methods_screen.dart @@ -17,43 +17,63 @@ class PaymentMethodsScreen extends StatelessWidget { body: Container( decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), child: SafeArea( - child: Column( - children: [ - const SizedBox(height: 16), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: CustomAppBar(title: 'Способы оплаты'), - ), - const SizedBox(height: 24), - Expanded( - child: BlocConsumer( - listener: (context, state) { - if (state.status == PaymentMethodsStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), - ); - } - }, - builder: (context, state) { - if (state.status == PaymentMethodsStatus.loading && state.cards.isEmpty) { - return const Center(child: CircularProgressIndicator(color: Color(0xFF00D4AA))); - } + child: BlocConsumer( + listener: (context, state) { + if (state.status == PaymentMethodsStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), + ); + } + }, + builder: (context, state) { + final isNetworkProcessing = state.status == PaymentMethodsStatus.loading || + (state.isDeleting ?? false) || + (state.isSettingMain ?? false); - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildBalanceCard(context, state.balance), - const SizedBox(height: 20), - _buildCardsList(context, state), - ], + return Stack( + children: [ + Column( + children: [ + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: CustomAppBar(title: 'Способы оплаты'), ), - ); - }, - ), - ), - ], + const SizedBox(height: 24), + Expanded( + child: state.cards.isEmpty && state.status == PaymentMethodsStatus.loading + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF00D4AA)), + ) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBalanceCard(context, state.balance), + const SizedBox(height: 20), + _buildCardsList(context, state), + ], + ), + ), + ), + ], + ), + + if (isNetworkProcessing && state.cards.isNotEmpty) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.4), + child: const Center( + child: CircularProgressIndicator( + color: Color(0xFF00D4AA), + ), + ), + ), + ), + ], + ); + }, ), ), ), @@ -163,7 +183,13 @@ class PaymentMethodsScreen extends StatelessWidget { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => context.go('/home/payment-methods/add-card'), + onTap: () async { + final isCardAdded = await context.push('/home/payment-methods/add-card'); + + if (isCardAdded == true && context.mounted) { + context.read().add(PaymentMethodsStarted()); + } + }, borderRadius: BorderRadius.circular(24), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/presentation/screens/profile_screen.dart b/lib/presentation/screens/profile_screen.dart index c1ce386..916b3fd 100644 --- a/lib/presentation/screens/profile_screen.dart +++ b/lib/presentation/screens/profile_screen.dart @@ -50,7 +50,9 @@ class _ProfileScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: Container( - decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + decoration: const BoxDecoration( + gradient: AppColors.phoneScreenBg, + ), child: SafeArea( child: BlocBuilder( builder: (context, state) { @@ -70,56 +72,82 @@ class _ProfileScreenState extends State { } final profile = state.profile!; - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 16), - CustomAppBar(title: 'Профиль'), - const SizedBox(height: 32), - Stack( - alignment: Alignment.topRight, - children: [ - CircleAvatar( - radius: 60, - backgroundColor: AppColors.checkboxFill, - backgroundImage: (profile.avatarUrl != null && profile.avatarUrl!.isNotEmpty) - ? NetworkImage("${profile.avatarUrl!}?v=${DateTime.now().minute}") - : null, - child: (profile.avatarUrl == null || profile.avatarUrl!.isEmpty) - ? Text( - profile.name.isNotEmpty ? profile.name[0].toUpperCase() : '', - style: const TextStyle(fontSize: 50, color: AppColors.darkBlue), - ) - : null, - ), GestureDetector( - onTap: _pickImage, - child: Container( - margin: const EdgeInsets.only(top: 0, right: 0), - child: Image.asset( - 'assets/icons/edit.png', - width: 24, - height: 24, + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 16), + CustomAppBar(title: 'Профиль'), + const SizedBox(height: 32), + + Stack( + alignment: Alignment.topRight, + children: [ + CircleAvatar( + radius: 60, + backgroundColor: AppColors.checkboxFill, + backgroundImage: + (profile.avatarUrl != null && + profile.avatarUrl!.isNotEmpty) + ? NetworkImage( + "${profile.avatarUrl!}?v=${DateTime.now().minute}", + ) + : null, + child: + (profile.avatarUrl == null || + profile.avatarUrl!.isEmpty) + ? Text( + profile.name.isNotEmpty + ? profile.name[0].toUpperCase() + : '', + style: const TextStyle( + fontSize: 50, + color: AppColors.darkBlue, + ), + ) + : null, + ), + + GestureDetector( + onTap: _pickImage, + child: Container( + child: Image.asset( + 'assets/icons/edit.png', + width: 24, + height: 24, + ), + ), + ), + ], ), - ), + + const SizedBox(height: 32), + + _ProfileInfoBlock( + profile: profile, + onEditTap: () => context.go("/home/profile/edit"), + ), + + // const SizedBox(height: 24), + // _SettingsBlock( + // notificationsEnabled: notificationsEnabled, + // onNotificationsChanged: (v) => + // setState(() => notificationsEnabled = v), + // ), + ], ), - ], + ), ), - const SizedBox(height: 32), - _ProfileInfoBlock( - profile: profile, - onEditTap: () => context.go("/home/profile/edit"), - ), - const SizedBox(height: 24), - _SettingsBlock( - notificationsEnabled: notificationsEnabled, - onNotificationsChanged: (v) => - setState(() => notificationsEnabled = v), - ), - const SizedBox(height: 24), - ], - ), + ); + }, ); }, ), diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart index b4805b4..695b783 100644 --- a/lib/presentation/screens/splash_screen.dart +++ b/lib/presentation/screens/splash_screen.dart @@ -1,13 +1,8 @@ - +import 'dart:math' as math; import 'package:be_happy/presentation/event/spalsh_event.dart'; import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -// Подключи сюда свои реальные экраны: -import 'phone_screen.dart'; -import 'pin_login_screen.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -19,35 +14,61 @@ class SplashScreen extends StatefulWidget { class _SplashScreenState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final Animation _revealAnimation; - static const double logoSize = 300; + // Фаза 1: Заполнение цветом слева направо (0.0 -> 0.5) + late final Animation _fillProgress; + + // Фаза 2: Укатывание вправо (0.6 -> 1.0) + late final Animation _rollTranslation; + late final Animation _rollRotation; + + // Уменьшенный размер логотипа по вашему запросу + static const double logoSize = 130; @override void initState() { super.initState(); - // контроллер анимации _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 2500), + duration: const Duration(milliseconds: 3000), ); - // анимация движения "затемняющего" прямоугольника - _revealAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + _fillProgress = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeInOut), + ), ); - // запускаем анимацию - _controller.forward().then((_) async { - // небольшая пауза после анимации - await Future.delayed(const Duration(milliseconds: 800)); - if (!mounted) { - return; - } - context.read().add(AuthCheckRequested()); + _rollTranslation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.6, 1.0, curve: Curves.easeInCubic), + ), + ); + + _rollRotation = Tween(begin: 0.0, end: 2 * math.pi).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ); + + // Добавляем задержку перед стартом, чтобы пользователь успел увидеть экран + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + + // Ждем 500мс после того, как первый кадр отрисовался + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) return; + // Запускаем анимацию + _controller.forward().then((_) { + if (!mounted) return; + context.read().add(AuthCheckRequested()); + }); }); - } @override @@ -56,69 +77,102 @@ class _SplashScreenState extends State super.dispose(); } - @override @override Widget build(BuildContext context) { + final double screenWidth = MediaQuery.of(context).size.width; + final double endTranslation = screenWidth / 2 + logoSize; + return Scaffold( - backgroundColor: const Color(0xFF3A3A3A), - body: Center( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final double offset = _revealAnimation.value * (logoSize * 1.2); - - return Stack( - alignment: Alignment.center, - children: [ - // Цветной логотип (на заднем плане) - Image.asset( - 'assets/logo_color.png', - width: logoSize, - height: logoSize, - fit: BoxFit.contain, + body: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF293A69), + Color(0xFF202741), + ], ), + ), + ), + ), - // Прямоугольник, который "уезжает" вправо, открывая логотип - ClipRect( - child: Align( - alignment: Alignment.centerLeft, - child: FractionallySizedBox( - widthFactor: 1, - child: Container( - width: logoSize, - height: logoSize, - color: const Color(0xFF3A3A3A), - transform: Matrix4.translationValues(offset, 0, 0), - ), + // 2. Волна + Positioned( + top: 0, + left: 0, + right: 0, + child: Image.asset( + 'assets/wave.png', + fit: BoxFit.contain, + ), + ), + + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Image.asset( + 'assets/splash_map.png', + fit: BoxFit.contain, + ), + ), + + Center( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final double translationX = _rollTranslation.value * endTranslation; + + return Transform( + transform: Matrix4.translationValues(translationX, 0, 0) + ..rotateZ(_rollRotation.value), + alignment: Alignment.center, + // Используем ShaderMask для эффекта заполнения/проявления + child: ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + // Используем _fillProgress для сдвига жесткой границы градиента + colors: const [Colors.white, Colors.transparent], + stops: [_fillProgress.value, _fillProgress.value], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, // Оставляет только ту часть логотипа, где градиент белый + child: Image.asset( + 'assets/splash_logo.png', + width: logoSize, + height: logoSize, + fit: BoxFit.contain, ), ), - ), - - // Обводка логотипа (поверх) - Image.asset( - 'assets/logo_outline.png', - width: logoSize * 1.01, - height: logoSize * 1.01, - fit: BoxFit.contain, - ), - ], - ); - }, - ), - ), - - bottomNavigationBar: Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Text( - 'Версия приложения 1.0', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 12, + ); + }, + ), ), - ), + + // 5. Версия приложения + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Text( + 'Версия приложения 1.0', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 12, + fontWeight: FontWeight.w300, + letterSpacing: 0.5, + ), + ), + ), + ], ), ); } - -} +} \ No newline at end of file diff --git a/lib/presentation/screens/subscription_details_screen.dart b/lib/presentation/screens/subscription_details_screen.dart index ffde1ce..91f4694 100644 --- a/lib/presentation/screens/subscription_details_screen.dart +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -27,15 +27,26 @@ class SubscriptionDetailsScreen extends StatelessWidget { const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: BlocBuilder( - builder: (context, state) { - String title = "Загрузка..."; - if (state is DetailsContentState) { - title = state.subscription.title; - } - return CustomAppBar(title: title); - }, - ), + child: + BlocConsumer< + SubscriptionDetailsBloc, + SubscriptionDetailsState + >( + listenWhen: (previous, current) => + current is DetailsContentState && current.isSuccess, + listener: (context, state) { + if (state is DetailsContentState && state.isSuccess) { + context.pop(true); + } + }, + builder: (context, state) { + String title = "Загрузка..."; + if (state is DetailsContentState) { + title = state.subscription.title; + } + return CustomAppBar(title: title); + }, + ), ), const SizedBox(height: 20), @@ -53,11 +64,16 @@ class SubscriptionDetailsScreen extends StatelessWidget { child: Image.asset('assets/wave.png'), ), ), - BlocBuilder( + BlocBuilder< + SubscriptionDetailsBloc, + SubscriptionDetailsState + >( builder: (context, state) { if (state is DetailsLoading) { return const Center( - child: CircularProgressIndicator(color: Color(0xFF80FFD1)), + child: CircularProgressIndicator( + color: Color(0xFF80FFD1), + ), ); } if (state is DetailsError) { @@ -85,6 +101,7 @@ class SubscriptionDetailsScreen extends StatelessWidget { } Widget _buildContent(BuildContext context, DetailsContentState state) { + final bool isAvailableForPurchase = state.subscription.isActive; return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), child: Column( @@ -98,22 +115,28 @@ class SubscriptionDetailsScreen extends StatelessWidget { height: 1.5, ), ), - const SizedBox(height: 30), - _ActionCard(state: state), + if (isAvailableForPurchase) ...[ + const SizedBox(height: 30), - const SizedBox(height: 30), + _ActionCard(state: state), - GradientButton( - text: 'Активировать', - onTap: () => context.read().add( - ActivateSubscriptionPressed(), + const SizedBox(height: 30), + + GradientButton( + text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать', + onTap: () { + context.read().add( + ActivateSubscriptionPressed(), + ); + }, + enabled: state.isAgreed, + width: double.infinity, + height: 56, + fontSize: 16, + showArrows: true, ), - width: double.infinity, - height: 56, - fontSize: 16, - showArrows: true, - ), + ], const SizedBox(height: 20), ], ), @@ -232,4 +255,4 @@ class _PriceRow extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/screens/subscription_list_screen.dart b/lib/presentation/screens/subscription_list_screen.dart index d5094c6..c6b1b89 100644 --- a/lib/presentation/screens/subscription_list_screen.dart +++ b/lib/presentation/screens/subscription_list_screen.dart @@ -1,3 +1,4 @@ +import 'package:be_happy/presentation/event/subscription_list_event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -44,17 +45,26 @@ class SubscriptionsListScreen extends StatelessWidget { if (state is SubscriptionsLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is SubscriptionsLoaded) { - final activeIds = state.activeSubscriptions.map((e) => e.id).toSet(); + final clientSubsMap = { + for (var sub in state.activeSubscriptions) sub.subscriptionId: sub + }; + return ListView.builder( padding: const EdgeInsets.only(top: 20), itemCount: state.subscriptions.length, itemBuilder: (context, index) { final subscription = state.subscriptions[index]; - final bool isActive = activeIds.contains(subscription.id); + + final bool isPurchased = subscription.isCurrent; + + final clientSub = clientSubsMap[subscription.id]; + final DateTime? expirationDate = isPurchased ? clientSub?.expiredAt : null; return SubscriptionCard( subscription: subscription, - isActive: isActive, + isActive: isPurchased, + expiredAt: expirationDate, + onRefresh: () { context.read().add(LoadSubscriptionsEvent());}, ); }, ); diff --git a/lib/presentation/screens/top_up_screen.dart b/lib/presentation/screens/top_up_screen.dart index ba83f86..6e772c6 100644 --- a/lib/presentation/screens/top_up_screen.dart +++ b/lib/presentation/screens/top_up_screen.dart @@ -170,7 +170,7 @@ class TopUpScreen extends StatelessWidget { create: (context) => PaymentMethodSheetBloc(getIt()) ..add(PaymentMethodSheetStarted()), - child: const PaymentMethodSheet(), + child: const PaymentMethodSheet(showBalance: false,), ), ); diff --git a/lib/presentation/state/active_ride_state.dart b/lib/presentation/state/active_ride_state.dart index b04c81a..3f87931 100644 --- a/lib/presentation/state/active_ride_state.dart +++ b/lib/presentation/state/active_ride_state.dart @@ -12,6 +12,8 @@ class ActiveRideState { final double cost; final bool isPaused; final bool inZone; + final double latitude; + final double longitude; const ActiveRideState({ this.status = ActiveRideStatus.initial, @@ -20,6 +22,8 @@ class ActiveRideState { this.elapsedTime = Duration.zero, this.speed = 0.0, this.distance = 0.0, + this.latitude = 0.0, + this.longitude = 0.0, this.cost = 0.0, this.isPaused = false, this.inZone = true, @@ -33,6 +37,8 @@ class ActiveRideState { double? speed, double? distance, double? cost, + double? longitude, + double? latitude, bool? isPaused, bool? inZone, }) { @@ -44,6 +50,8 @@ class ActiveRideState { speed: speed ?? this.speed, distance: distance ?? this.distance, cost: cost ?? this.cost, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, isPaused: isPaused ?? this.isPaused, inZone: inZone ?? this.inZone, ); diff --git a/lib/presentation/state/map_state.dart b/lib/presentation/state/map_state.dart index 3c411a4..1c2a72e 100644 --- a/lib/presentation/state/map_state.dart +++ b/lib/presentation/state/map_state.dart @@ -9,6 +9,8 @@ enum ScooterStatus { initial, loading, success, failure } class ScooterState { final List scooters; + final List reservedScooters; + final Scooter? selectedScooterForFocus; final List zones; final List area; final List areaScooters; @@ -23,6 +25,8 @@ class ScooterState { ScooterState({ this.scooters = const [], + this.reservedScooters = const [], + this.selectedScooterForFocus, this.zones = const [], this.area = const [], this.areaScooters = const [], @@ -38,6 +42,8 @@ class ScooterState { ScooterState copyWith({ List? scooters, + List? reservedScooters, + Scooter? selectedScooterForFocus, List? zones, List? area, List? areaScooters, @@ -52,6 +58,8 @@ class ScooterState { }) { return ScooterState( scooters: scooters ?? this.scooters, + reservedScooters: reservedScooters ?? this.reservedScooters, + selectedScooterForFocus: selectedScooterForFocus ?? this.selectedScooterForFocus, zones: zones ?? this.zones, area: area ?? this.area, areaScooters: areaScooters ?? this.areaScooters, diff --git a/lib/presentation/state/subscription_list_state.dart b/lib/presentation/state/subscription_list_state.dart index 24596a1..1db5045 100644 --- a/lib/presentation/state/subscription_list_state.dart +++ b/lib/presentation/state/subscription_list_state.dart @@ -1,3 +1,5 @@ +import 'package:be_happy/domain/entities/client_subscription.dart'; + import '../../domain/entities/subscription.dart'; abstract class SubscriptionState {} @@ -6,7 +8,7 @@ class SubscriptionsLoading extends SubscriptionState {} class SubscriptionsLoaded extends SubscriptionState { final List subscriptions; - final List activeSubscriptions; + final List activeSubscriptions; SubscriptionsLoaded({ required this.subscriptions, diff --git a/lib/presentation/state/susbcription_details_state.dart b/lib/presentation/state/susbcription_details_state.dart index 1bc7d58..7ce8636 100644 --- a/lib/presentation/state/susbcription_details_state.dart +++ b/lib/presentation/state/susbcription_details_state.dart @@ -15,21 +15,29 @@ class DetailsContentState extends SubscriptionDetailsState { final Subscription subscription; final SubscriptionPeriod selectedPeriod; final bool isAgreed; + final bool isAlreadyPurchased; // ✅ Куплена ли эта подписка сейчас + final bool isSuccess; // ✅ Сигнал для навигатора назад DetailsContentState({ required this.subscription, required this.selectedPeriod, this.isAgreed = false, + this.isAlreadyPurchased = false, + this.isSuccess = false, }); DetailsContentState copyWith({ SubscriptionPeriod? selectedPeriod, bool? isAgreed, + bool? isAlreadyPurchased, + bool? isSuccess, }) { return DetailsContentState( subscription: this.subscription, selectedPeriod: selectedPeriod ?? this.selectedPeriod, isAgreed: isAgreed ?? this.isAgreed, + isAlreadyPurchased: isAlreadyPurchased ?? this.isAlreadyPurchased, + isSuccess: isSuccess ?? this.isSuccess, ); } } \ No newline at end of file diff --git a/lib/presentation/viewmodel/active_ride_bloc.dart b/lib/presentation/viewmodel/active_ride_bloc.dart index 661e95d..b525bc0 100644 --- a/lib/presentation/viewmodel/active_ride_bloc.dart +++ b/lib/presentation/viewmodel/active_ride_bloc.dart @@ -76,6 +76,8 @@ class ActiveRideBloc extends Bloc { cost: orderData?.price ?? 0.0, isPaused: isPaused, inZone: orderData?.zone, + latitude: orderData?.latitude, + longitude: orderData?.longitude, )); _syncTimer?.cancel(); @@ -196,6 +198,8 @@ class ActiveRideBloc extends Bloc { cost: orderData?.price ?? state.cost, isPaused: isPaused, inZone: orderData?.zone, + latitude: orderData?.latitude, + longitude: orderData?.longitude, )); } print("CURRENT STATE $state"); diff --git a/lib/presentation/viewmodel/map_bloc.dart b/lib/presentation/viewmodel/map_bloc.dart index d06169a..72268d0 100644 --- a/lib/presentation/viewmodel/map_bloc.dart +++ b/lib/presentation/viewmodel/map_bloc.dart @@ -5,6 +5,7 @@ import 'package:be_happy/domain/entities/client_notification.dart'; import 'package:be_happy/domain/entities/map_settings.dart'; import 'package:be_happy/domain/usecase/check_user_usecase.dart'; import 'package:be_happy/domain/usecase/get_available_zones_usecase.dart'; +import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart'; import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; import 'package:be_happy/domain/usecase/get_notifications_stream_usecase.dart'; import 'package:be_happy/domain/usecase/logout_usecase.dart'; @@ -13,6 +14,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/entities/point.dart'; import '../../domain/entities/scooter.dart'; +import '../../domain/entities/scooter_order.dart'; import '../../domain/entities/zone.dart'; import '../../domain/usecase/get_available_scooters_usecase.dart'; import '../../domain/usecase/get_profile_usecase.dart'; @@ -23,6 +25,7 @@ import 'package:maps_toolkit/maps_toolkit.dart' as mt; class MapBloc extends Bloc { final GetAvailableScootersUsecase getScootersUsecase; + final GetClientOrdersUsecase getClientOrdersUsecase; final GetAvailableZonesUsecase getAvailableZonesUsecase; final GetMapSettingsUsecase getMapSettingsUsecase; final GetNotificationsStreamUseCase getNotificationsStreamUseCase; @@ -35,6 +38,7 @@ class MapBloc extends Bloc { MapBloc( this.getAvailableZonesUsecase, this.getScootersUsecase, + this.getClientOrdersUsecase, this.getMapSettingsUsecase, this.getNotificationsStreamUseCase, this.getProfileUseCase, @@ -49,6 +53,9 @@ class MapBloc extends Bloc { on(_onFetchProfileData); on(_onCheckUser); on(_onLogoutPressed); + on(_onFocusOnScooter); + on(_onClearMapPlacemarks); + on(_onClearMapFocus); } void startNotificationStream() { @@ -97,11 +104,15 @@ class MapBloc extends Bloc { getScootersUsecase(event.areaScooters, 0, 100), getAvailableZonesUsecase(event.area, 0, 100), getMapSettingsUsecase(), + getClientOrdersUsecase(), ]); final scooters = results[0] as List; final zones = results[1] as List; final settings = results[2] as MapSettings; + final orders = results[3]; + + zones.forEach(print); @@ -127,6 +138,7 @@ class MapBloc extends Bloc { state.copyWith( status: ScooterStatus.success, scooters: scooters, + // reservedScooters: reservedScooters, zones: filteredZones, area: event.area, areaScooters: event.areaScooters, @@ -236,7 +248,9 @@ class MapBloc extends Bloc { try { final profile = await getProfileUseCase(); - emit(state.copyWith(phoneNumber: profile.phone, balance: profile.balance)); + emit( + state.copyWith(phoneNumber: profile.phone, balance: profile.balance), + ); } catch (e) { emit( state.copyWith( @@ -280,4 +294,52 @@ class MapBloc extends Bloc { ); } } + + FutureOr _onFocusOnScooter(FocusOnScooter event, Emitter emit) { + final updatedReserved = List.from(state.reservedScooters ?? []); + if (!updatedReserved.any((s) => s.id == event.scooter.id)) { + updatedReserved.add(event.scooter); + } + + emit(state.copyWith( + selectedScooterForFocus: event.scooter, + reservedScooters: updatedReserved, + )); + } + + + + FutureOr _onClearMapPlacemarks(ClearMapPlacemarks event, Emitter emit) async { + try{ + final orders = await getClientOrdersUsecase(); + + List updatedReservedScooters = []; + + if (orders is Success>) { + print("FETCH: orders.data.length = ${orders.data?.length}"); + + updatedReservedScooters = orders.data?.map((order) { + return order.scooter; + }).toList() ?? []; + } + + emit(state.copyWith( + reservedScooters: updatedReservedScooters, + )); + } catch (e) { + print("Error in _onClearMapPlacemarks: $e"); + emit(state.copyWith( + status: ScooterStatus.failure, + errorMessage: e.toString(), + )); + } + } + + FutureOr _onClearMapFocus(ClearMapFocus event, Emitter emit) { + emit(state.copyWith( + selectedScooterForFocus: null, + )); + } } + + diff --git a/lib/presentation/viewmodel/order_history_bloc.dart b/lib/presentation/viewmodel/order_history_bloc.dart index ff690f4..fb7e752 100644 --- a/lib/presentation/viewmodel/order_history_bloc.dart +++ b/lib/presentation/viewmodel/order_history_bloc.dart @@ -3,7 +3,6 @@ import 'package:be_happy/domain/entities/scooter_order.dart'; import 'package:be_happy/domain/usecase/get_scooter_order_history_usecase.dart'; import 'package:be_happy/core/result.dart'; -// 🔹 EVENTS abstract class OrderHistoryEvent {} class OrderHistoryFetchRequested extends OrderHistoryEvent { @@ -13,7 +12,6 @@ class OrderHistoryFetchRequested extends OrderHistoryEvent { class OrderHistoryRefreshRequested extends OrderHistoryEvent {} -// 🔹 STATES enum OrderHistoryStatus { initial, loading, success, failure, empty } class OrderHistoryState { diff --git a/lib/presentation/viewmodel/payment_confirm_bloc.dart b/lib/presentation/viewmodel/payment_confirm_bloc.dart index 826de8e..4ce9f0f 100644 --- a/lib/presentation/viewmodel/payment_confirm_bloc.dart +++ b/lib/presentation/viewmodel/payment_confirm_bloc.dart @@ -89,12 +89,11 @@ class PaymentConfirmBloc event.isBalance, ); - if (result is Success) { + if (result is Success) { emit( state.copyWith( status: PaymentConfirmStatus.success, paymentCompleted: true, - ), ); } else if (result is Failure) { diff --git a/lib/presentation/viewmodel/payment_methods_bloc.dart b/lib/presentation/viewmodel/payment_methods_bloc.dart index 48bca02..7ac3819 100644 --- a/lib/presentation/viewmodel/payment_methods_bloc.dart +++ b/lib/presentation/viewmodel/payment_methods_bloc.dart @@ -70,13 +70,16 @@ class PaymentMethodsBloc extends Bloc PaymentMethodsDeleteCard event, Emitter emit, ) async { - emit(state.copyWith(isDeleting: true)); + emit(state.copyWith( + status: PaymentMethodsStatus.loading, + isDeleting: true, + )); try { final result = await _removePaymentCardUsecase(event.cardId); if (result is Success) { - emit(state.copyWith(isDeleting: false)); + emit(state.copyWith(isDeleting: false, status: PaymentMethodsStatus.success)); add(PaymentMethodsStarted()); } else if (result is Failure) { String errorMessage = 'Не удалось удалить карту'; diff --git a/lib/presentation/viewmodel/subscription_list_bloc.dart b/lib/presentation/viewmodel/subscription_list_bloc.dart index eee7195..78a0086 100644 --- a/lib/presentation/viewmodel/subscription_list_bloc.dart +++ b/lib/presentation/viewmodel/subscription_list_bloc.dart @@ -1,4 +1,5 @@ import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/client_subscription.dart'; import 'package:be_happy/domain/entities/subscription.dart'; import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart'; import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart'; @@ -11,6 +12,7 @@ class SubscriptionListBloc extends Bloc { final GetAvailableSubscriptionsUsecase getAvailableSubscriptionsUsecase; final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; + SubscriptionListBloc({ required this.getAvailableSubscriptionsUsecase, required this.getClientSubscriptionsUsecase, @@ -26,10 +28,25 @@ class SubscriptionListBloc extends Bloc { final allResult = results[0]; final activeResult = results[1] ; - if (allResult is Success> && activeResult is Success>) { + if (allResult is Success> && activeResult is Success>) { + final availableSubs = allResult.data ?? []; + final clientSubs = activeResult.data ?? []; + + final Map combinedSubsMap = {}; + + for (var clientSub in clientSubs) { + if (clientSub.subscription != null) { + combinedSubsMap[clientSub.subscriptionId] = clientSub.subscription; + } + } + + for (var sub in availableSubs) { + combinedSubsMap[sub.id] = sub; + } + emit(SubscriptionsLoaded( - subscriptions: allResult.data ?? [], - activeSubscriptions: activeResult.data ?? [], + subscriptions: combinedSubsMap.values.toList(), + activeSubscriptions: clientSubs, )); } else { emit(SubscriptionsError("Не удалось загрузить данные из API")); diff --git a/lib/presentation/viewmodel/susbcription_details_bloc.dart b/lib/presentation/viewmodel/susbcription_details_bloc.dart index 68f2265..24d4208 100644 --- a/lib/presentation/viewmodel/susbcription_details_bloc.dart +++ b/lib/presentation/viewmodel/susbcription_details_bloc.dart @@ -4,38 +4,59 @@ import 'package:be_happy/domain/usecase/activate_subscription_usecase.dart'; import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/client_subscription.dart'; +import '../../domain/usecase/get_client_subscriptions_usecase.dart'; import '../event/subscription_details_event.dart'; import '../state/susbcription_details_state.dart'; -class SubscriptionDetailsBloc extends Bloc { +class SubscriptionDetailsBloc + extends Bloc { final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase; final ActivateSubscriptionUsecase activateSubscriptionUsecase; + final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; - SubscriptionDetailsBloc(this.getSubscriptionByIdUsecase, - this.activateSubscriptionUsecase) : super(DetailsLoading()) { + SubscriptionDetailsBloc( + this.getSubscriptionByIdUsecase, + this.activateSubscriptionUsecase, + this.getClientSubscriptionsUsecase, + ) : super(DetailsLoading()) { on((event, emit) async { emit(DetailsLoading()); try { - final result = await getSubscriptionByIdUsecase(event.subscriptionId); - switch (result) { + final clientSubsResult = await getClientSubscriptionsUsecase(); + bool isPurchased = false; - case Success(): - final sub = result.data; - - if (sub == null) return; - - emit(DetailsContentState( - subscription: sub, - selectedPeriod: sub.options.first, - )); - case Failure(): - emit(DetailsError("Ошибка при запросе данных")); + switch (clientSubsResult) { + case Success>(): + isPurchased = clientSubsResult.data?.any( + (element) => element.subscriptionId == event.subscriptionId, + ) ?? false; + break; + case Failure>(): + isPurchased = false; + break; } + switch (result) { + case Success(): + final sub = result.data; + if (sub == null) return; + emit( + DetailsContentState( + subscription: sub, + selectedPeriod: sub.options.first, + isAlreadyPurchased: isPurchased, + ), + ); + break; + case Failure(): + emit(DetailsError("Ошибка при запросе данных")); + break; + } } catch (e) { emit(DetailsError("Не удалось загрузить данные")); } @@ -43,7 +64,9 @@ class SubscriptionDetailsBloc extends Bloc((event, emit) { if (state is DetailsContentState) { - emit((state as DetailsContentState).copyWith(selectedPeriod: event.period)); + emit( + (state as DetailsContentState).copyWith(selectedPeriod: event.period), + ); } }); @@ -53,14 +76,23 @@ class SubscriptionDetailsBloc extends Bloc((event, emit) { - switch(state) { + on((event, emit) async { + switch (state) { case DetailsContentState contentState: - activateSubscriptionUsecase(contentState.selectedPeriod.id); + if (!contentState.isAgreed) { + return; + } + + final result = await activateSubscriptionUsecase(contentState.selectedPeriod.id); + + if (result is Success) { + emit(contentState.copyWith(isSuccess: true)); + } else { + } break; default: break; } }); } -} \ No newline at end of file +} diff --git a/pubspec.yaml b/pubspec.yaml index 4ab4aac..b687ce5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,11 @@ flutter: - assets/ - assets/icons/ + fonts: + - family: "DigitalNumbers" + fonts: + - asset: assets/fonts/DigitalNumbers-Regular.ttf + # fonts: # - family: Schyler