diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib.zip b/lib.zip new file mode 100644 index 0000000..ccc52f4 Binary files /dev/null and b/lib.zip differ diff --git a/lib/data/models/client_notification_dto.dart b/lib/data/models/client_notification_dto.dart index 317e830..dc5ba1e 100644 --- a/lib/data/models/client_notification_dto.dart +++ b/lib/data/models/client_notification_dto.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import '../../domain/entities/client_notification.dart'; class ClientNotificationDto { @@ -43,12 +42,19 @@ class ClientNotificationDto { clientId: clientId, type: _parseType(type), category: _parseCategory(category), - createdAt: DateTime.parse(createdAt), - canceledAt: canceledAt != null ? DateTime.parse(canceledAt!) : null, - readAt: readAt != null ? DateTime.parse(readAt!) : null, + createdAt: _parseUtcDate(createdAt), + canceledAt: canceledAt != null ? _parseUtcDate(canceledAt!) : null, + readAt: readAt != null ? _parseUtcDate(readAt!) : null, ); } + DateTime _parseUtcDate(String dateString) { + if (dateString.endsWith('Z') || dateString.contains('+') || dateString.contains('-')) { + return DateTime.parse(dateString).toLocal(); + } + return DateTime.parse('${dateString}Z').toLocal(); + } + NotificationType _parseType(String type) { switch (type.toLowerCase()) { case 'info': @@ -80,4 +86,4 @@ class ClientNotificationDto { return NotificationCategory.companyInfo; } } -} +} \ No newline at end of file diff --git a/lib/data/network/api_service.dart b/lib/data/network/api_service.dart index 51f7874..b2e4a07 100644 --- a/lib/data/network/api_service.dart +++ b/lib/data/network/api_service.dart @@ -147,11 +147,12 @@ class ApiService { } dynamic balanceRaw = profileData["balance"]; - int? balance; - if (balanceRaw is int) { - balance = balanceRaw; + double? balance; + + if (balanceRaw is num) { + balance = balanceRaw.toDouble(); } else if (balanceRaw is String) { - balance = int.tryParse(balanceRaw); + balance = double.tryParse(balanceRaw); } return UserProfile( @@ -238,11 +239,11 @@ class ApiService { } dynamic balanceRaw = data["balance"]; - int? balance; - if (balanceRaw is int) { + double? balance; + if (balanceRaw is double) { balance = balanceRaw; } else if (balanceRaw is String) { - balance = int.tryParse(balanceRaw); + balance = double.tryParse(balanceRaw); } return UserProfile( @@ -420,7 +421,7 @@ class ApiService { int page = 1, int pageSize = 500, }) async { - const path = "$baseUrl/zone/available"; + const path = "$baseUrl/zone/available2"; final queryParams = { 'readAll': 'true', @@ -708,17 +709,13 @@ class ApiService { } } - Future cancelRide(int orderId) async { + Future cancelRide(int orderId) async { try { final response = await _dio.put( "$baseUrl/scooterorder/$orderId/cancel", options: await _getAuthOptions(), ); - if (response.statusCode == 200 || response.statusCode == 201) { - return ScooterOrder.fromJson(response.data); - } - return null; } on DioException catch (e) { _handleDioError(e); return null; @@ -759,7 +756,7 @@ class ApiService { } } - Future finishRide({ + Future finishRide({ required int orderId, required List filesId, }) async { @@ -770,10 +767,6 @@ class ApiService { options: await _getAuthOptions(), ); - if (response.statusCode == 200 || response.statusCode == 201) { - return ScooterOrder.fromJson(response.data); - } - return null; } on DioException catch (e) { if (e.response?.statusCode == 400) { final data = e.response?.data; @@ -1013,10 +1006,10 @@ class ApiService { if (event.data != null && event.data!.isNotEmpty) { try { final Map jsonData = jsonDecode(event.data!); - print("✅ [SSE] Parsed JSON: $jsonData"); + print("[SSE] Parsed JSON: $jsonData"); controller.add(jsonData); } catch (e) { - print("❌ [SSE] JSON Parse Error: $e | Raw: ${event.data}"); + print("[SSE] JSON Parse Error: $e | Raw: ${event.data}"); } } }, @@ -1034,17 +1027,19 @@ class ApiService { return controller.stream; } - Future>> getNotifications() async { - final url = Uri.parse('$baseUrl/notification/client'); + Future> getNotifications({ + int page = 1, + int pageSize = 10, + }) async { + final url = Uri.parse('$baseUrl/notification/client').replace( + queryParameters: { + 'page': page.toString(), + 'pageSize': pageSize.toString(), + }, + ); final accessToken = await _securityService.getAccessToken(); - if (accessToken == null) { - print("APISERVICE Error: Access token is null."); - throw UnauthorizedException(); - } - - print("GET NOTIFICATIONS REQUEST:"); - print("URL: $url"); + if (accessToken == null) throw UnauthorizedException(); final response = await http.get( url, @@ -1054,34 +1049,17 @@ class ApiService { }, ); - print("GET NOTIFICATIONS RESPONSE:"); - print("STATUS: ${response.statusCode}"); - print("BODY: ${response.body}"); - if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); - - // ✅ Проверяем, является ли ответ массивом или объектом с data[] - if (data is List) { - return data.cast>(); - } else if (data is Map) { - final list = data['data']; - if (list is List) { - return list.cast>(); - } else { - throw Exception( - 'Expected a List under "data" but got ${list.runtimeType}', - ); - } - } else { - throw Exception('Expected a List or Map but got ${data.runtimeType}'); + if (data is Map) { + return data; } + throw Exception('Expected Map with data and pagination'); } else if (response.statusCode == 401) { throw UnauthorizedException(); } else if (response.statusCode == 403) { throw AuthBlockException(); } - throw Exception('Ошибка сервера: ${response.statusCode}'); } diff --git a/lib/data/repositories/notification_repository_impl.dart b/lib/data/repositories/notification_repository_impl.dart index f7f0cdf..2e1b2d1 100644 --- a/lib/data/repositories/notification_repository_impl.dart +++ b/lib/data/repositories/notification_repository_impl.dart @@ -1,7 +1,9 @@ import 'package:be_happy/domain/entities/client_notification.dart'; import '../../data/network/api_service.dart'; +import '../../domain/entities/pagination_meta.dart'; import '../../domain/repositories/notification_repository.dart'; +import '../../domain/usecase/get_notifications_usecase.dart'; import '../models/client_notification_dto.dart'; class NotificationRepositoryImpl implements NotificationRepository { @@ -35,19 +37,27 @@ class NotificationRepositoryImpl implements NotificationRepository { } @override - Future> getNotifications() async { + Future getNotifications({ + int page = 1, + int pageSize = 10, + }) async { try { - final List> data = await _apiService.getNotifications(); - final notifications = data.map((json) { - final dto = ClientNotificationDto.fromJson(json); - return dto.toEntity(); - }).toList(); + final response = await _apiService.getNotifications( + page: page, + pageSize: pageSize, + ); - // dev.log('NotificationRepository: Загружено ${notifications.length} уведомлений'); + final notifications = (response['data'] as List) + .map((json) => ClientNotificationDto.fromJson(json).toEntity()) + .toList(); - return notifications; + final pagination = PaginationMeta.fromJson(response['pagination']); + + return NotificationsResult( + notifications: notifications, + pagination: pagination, + ); } catch (e, stackTrace) { - // dev.log('NotificationRepository: Ошибка: $e', stackTrace: stackTrace); throw Exception('Не удалось загрузить уведомления: $e'); } } diff --git a/lib/data/repositories/scooter_repository_impl.dart b/lib/data/repositories/scooter_repository_impl.dart index e39e751..110373e 100644 --- a/lib/data/repositories/scooter_repository_impl.dart +++ b/lib/data/repositories/scooter_repository_impl.dart @@ -192,15 +192,11 @@ class ScooterRepositoryImpl extends ScooterRepository { } @override - Future> cancelRide(int orderId) async { - late final Result result; + Future> cancelRide(int orderId) async { + late final Result result; try { - final order = await _apiService.cancelRide(orderId); - if (order != null) { - result = Success(order); - } else { - result = Failure(UnknownFailure("Неизвестная ошибка")); - } + await _apiService.cancelRide(orderId); + result = Success(null); } on AuthException catch (e) { result = Failure(AuthFailure(e.attemptsLeft)); } catch (e) { @@ -246,18 +242,11 @@ class ScooterRepositoryImpl extends ScooterRepository { } @override - Future> finishRide(int orderId, List files) async { + Future> finishRide(int orderId, List files) async { late final Result result; try { - final order = await _apiService.finishRide( - orderId: orderId, - filesId: files, - ); - if (order != null) { - result = Success(order); - } else { - result = Failure(UnknownFailure("Неизвестная ошибка")); - } + await _apiService.finishRide(orderId: orderId, filesId: files); + result = Success(null); } on AuthException catch (e) { result = Failure(AuthFailure(e.attemptsLeft)); } on WrongZoneException catch (e) { @@ -274,7 +263,6 @@ class ScooterRepositoryImpl extends ScooterRepository { try { await _apiService.payRide(orderId); result = Success(null); - } on AuthException catch (e) { result = Failure(AuthFailure(e.attemptsLeft)); } catch (e) { @@ -345,7 +333,7 @@ class ScooterRepositoryImpl extends ScooterRepository { cardId: cardId, isBalance: isBalance, ); - result = Success(null); + 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 a7e3a02..26f8ec0 100644 --- a/lib/di/service_locator.dart +++ b/lib/di/service_locator.dart @@ -305,9 +305,9 @@ Future setupDependencies() async { getIt.registerFactory(() => PhoneAuthBloc(getIt())); - getIt.registerFactory(() => VerifyCodeBloc(getIt(), getIt())); + getIt.registerFactory(() => VerifyCodeBloc(getIt(), getIt(), getIt())); - getIt.registerFactory(() => ProfileBloc(getIt(), getIt(), getIt())); + getIt.registerLazySingleton(() => ProfileBloc(getIt(), getIt(), getIt())); getIt.registerFactory( () => EditProfileBloc(getIt(), getIt()), @@ -337,7 +337,7 @@ Future setupDependencies() async { getIt.registerFactory( - () => PaymentMethodSheetBloc(getIt(), getIt()), + () => PaymentMethodSheetBloc(getIt(), getIt(), getIt()), ); getIt.registerFactory(() => CurrentRidesBloc(getIt())); diff --git a/lib/domain/entities/client_notification.dart b/lib/domain/entities/client_notification.dart index 59d4c7e..4f9d6d6 100644 --- a/lib/domain/entities/client_notification.dart +++ b/lib/domain/entities/client_notification.dart @@ -32,7 +32,7 @@ class ClientNotification { required this.createdAt, this.canceledAt, this.readAt, - }); + }) ; ClientNotification copyWith({ int? id, diff --git a/lib/domain/entities/pagination_meta.dart b/lib/domain/entities/pagination_meta.dart new file mode 100644 index 0000000..0ca97c9 --- /dev/null +++ b/lib/domain/entities/pagination_meta.dart @@ -0,0 +1,28 @@ +class PaginationMeta { + final int total; + final int lastPage; + final int currentPage; + final int totalPerPage; + final int? prevPage; + final int? nextPage; + + PaginationMeta({ + required this.total, + required this.lastPage, + required this.currentPage, + required this.totalPerPage, + this.prevPage, + this.nextPage, + }); + + factory PaginationMeta.fromJson(Map json) { + return PaginationMeta( + total: json['total'] as int, + lastPage: json['lastPage'] as int, + currentPage: json['currentPage'] as int, + totalPerPage: json['totalPerPage'] as int, + prevPage: json['prevPage'] as int?, + nextPage: json['nextPage'] as int?, + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/scooter_order.dart b/lib/domain/entities/scooter_order.dart index 9d815ca..8058c74 100644 --- a/lib/domain/entities/scooter_order.dart +++ b/lib/domain/entities/scooter_order.dart @@ -29,6 +29,9 @@ class ScooterOrder { final String? cancelDescription; final double mileage; //эту final double avgSpeed; //эту и снизу еще 4 есть + final String payTotalPricePrint; + final String unpayTotalPricePrint; + final String totalOrderPricePrint; ScooterOrder({ required this.id, @@ -57,11 +60,16 @@ class ScooterOrder { this.expiresAt, this.cancelAt, this.cancelDescription, - required this.mileage, //эту - required this.avgSpeed, //эту и снизу еще 2 есть + required this.mileage, + required this.avgSpeed, + required this.payTotalPricePrint, // оплачено + required this.unpayTotalPricePrint, //неоплачено + required this.totalOrderPricePrint, //общая }); factory ScooterOrder.fromJson(Map json) { + final paymentInfo = json['paymentInfo'] as Map?; + return ScooterOrder( id: json['id'] ?? 0, scooterId: json['scooterId'] ?? 0, @@ -101,8 +109,11 @@ class ScooterOrder { ? DateTime.parse(json['cancelAt']) : null, cancelDescription: json['cancelDescription'], - mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0, //эту - avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0, //эту + mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0, + avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0, + payTotalPricePrint: paymentInfo?['payTotalPricePrint'] ?? '', + unpayTotalPricePrint: paymentInfo?['unpayTotalPricePrint'] ?? '', + totalOrderPricePrint: paymentInfo?['totalOrderPricePrint'] ?? '', ); } diff --git a/lib/domain/entities/user_profile.dart b/lib/domain/entities/user_profile.dart index 0e72db9..3f13a18 100644 --- a/lib/domain/entities/user_profile.dart +++ b/lib/domain/entities/user_profile.dart @@ -3,7 +3,7 @@ class UserProfile { String birthDate; String phone; String email; - int? balance; + double? balance; int? avatarId; String? avatarUrl; @@ -21,7 +21,7 @@ class UserProfile { String? name, String? birthDate, String? email, - int? balance, + double? balance, int? avatarId, String? avatarUrl, }) { diff --git a/lib/domain/entities/zone.dart b/lib/domain/entities/zone.dart index ae57c59..be5d52f 100644 --- a/lib/domain/entities/zone.dart +++ b/lib/domain/entities/zone.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import 'package:be_happy/domain/entities/point.dart'; class Zone { @@ -9,7 +8,10 @@ class Zone { final String type; final bool isActive; final String shapeType; - final List points; + // Трехмерная структура для поддержки MultiPolygon и дыр (holes): + // geometry[индекс_полигона][индекс_кольца][индекс_точки] + // индекс_кольца == 0 — внешняя граница, > 0 — внутренние вырезы (дыры) + final List>> geometry; final String speedLimit; Zone({ @@ -19,38 +21,84 @@ class Zone { required this.type, required this.isActive, required this.shapeType, - required this.points, + required this.geometry, required this.speedLimit, }); + // Геттер для обратной совместимости (возвращает внешнее кольцо первого полигона) + List get points { + if (geometry.isNotEmpty && geometry[0].isNotEmpty) { + return geometry[0][0]; + } + return []; + } + factory Zone.fromJson(Map json) { final zoneCoordinates = json['coordinates'] as Map? ?? {}; final String coordsString = zoneCoordinates['coordinates'] ?? '[]'; final String shapeType = zoneCoordinates['type'] ?? 'Polygon'; - List points = []; + List>> parsedGeometry = []; try { final dynamic decoded = jsonDecode(coordsString); if (decoded is List && decoded.isNotEmpty) { - List targetList = []; - if (shapeType == 'Polygon') { - // У полигона структура [[[lat, lon], ...]] -> уходим на 1 уровень вглубь - targetList = decoded[0] as List; - } else { - // У LineString структура [[lat, lon], ...] -> используем как есть - targetList = decoded; - } + // Структура: [[[lon, lat], [lon, lat], ...], [hole1], ...] + List> polygonRings = []; + for (var ring in decoded) { + if (ring is List) { + List ringPoints = []; + for (var coord in ring) { + if (coord is List && coord.length >= 2) { + // В GeoJSON сначала идет Longitude, затем Latitude + ringPoints.add(Point( + (coord[1] as num).toDouble(), + (coord[0] as num).toDouble(), + )); + } + } + if (ringPoints.isNotEmpty) polygonRings.add(ringPoints); + } + } + if (polygonRings.isNotEmpty) parsedGeometry.add(polygonRings); - points = targetList.map((item) { - final List coords = item as List; - return Point( - (coords[1] as num).toDouble(), - (coords[0] as num).toDouble(), - ); - }).toList(); + } else if (shapeType == 'MultiPolygon') { + // Структура: [[[[lon, lat], ...]], [[[lon, lat], ...]]] + for (var poly in decoded) { + if (poly is List) { + List> polygonRings = []; + for (var ring in poly) { + if (ring is List) { + List ringPoints = []; + for (var coord in ring) { + if (coord is List && coord.length >= 2) { + ringPoints.add(Point( + (coord[1] as num).toDouble(), + (coord[0] as num).toDouble(), + )); + } + } + if (ringPoints.isNotEmpty) polygonRings.add(ringPoints); + } + } + if (polygonRings.isNotEmpty) parsedGeometry.add(polygonRings); + } + } + } else { + // Fallback для LineString или плоских списков + List ringPoints = []; + for (var coord in decoded) { + if (coord is List && coord.length >= 2) { + ringPoints.add(Point( + (coord[1] as num).toDouble(), + (coord[0] as num).toDouble(), + )); + } + } + if (ringPoints.isNotEmpty) parsedGeometry.add([ringPoints]); + } } } catch (e) { print("PARSE ERROR for Zone ID ${json['id']}: $e"); @@ -64,11 +112,12 @@ class Zone { isActive: json['isActive'] ?? false, speedLimit: json['speedLimit'] ?? '', shapeType: shapeType, - points: points, + geometry: parsedGeometry, ); } + @override String toString() { - return 'Zone{id: $id, title: $title, type: $type, points: $points}'; + return 'Zone{id: $id, title: $title, type: $type, polygonsCount: ${geometry.length}}'; } -} +} \ No newline at end of file diff --git a/lib/domain/repositories/notification_repository.dart b/lib/domain/repositories/notification_repository.dart index 7d58dac..8025fae 100644 --- a/lib/domain/repositories/notification_repository.dart +++ b/lib/domain/repositories/notification_repository.dart @@ -1,5 +1,7 @@ import 'package:be_happy/domain/entities/client_notification.dart'; +import '../usecase/get_notifications_usecase.dart'; + abstract class NotificationRepository { /// Устанавливает постоянное SSE-соединение и возвращает поток уведомлений Stream getNotificationsStream(); @@ -11,5 +13,7 @@ abstract class NotificationRepository { void closeStream(); /// получить список уведомлений - Future> getNotifications(); -} + Future getNotifications({ + int page = 1, + int pageSize = 10, + });} diff --git a/lib/domain/repositories/scooter_repository.dart b/lib/domain/repositories/scooter_repository.dart index 1e2be47..3d5f9ce 100644 --- a/lib/domain/repositories/scooter_repository.dart +++ b/lib/domain/repositories/scooter_repository.dart @@ -27,10 +27,10 @@ abstract class ScooterRepository { required bool isInsurance, }); Future> startRide(int orderId); - Future> cancelRide(int orderId); + Future> cancelRide(int orderId); Future> pauseRide(int orderId); Future> resumeRide(int orderId); - Future> finishRide(int orderId, List files); + Future> finishRide(int orderId, List files); Future> payRide(int orderId); Future>> getClientOrders(); Future>> uploadScooterPhotos(List images); diff --git a/lib/domain/usecase/cancel_ride_usecase.dart b/lib/domain/usecase/cancel_ride_usecase.dart index 379ffd1..a689474 100644 --- a/lib/domain/usecase/cancel_ride_usecase.dart +++ b/lib/domain/usecase/cancel_ride_usecase.dart @@ -7,7 +7,7 @@ class CancelRideUsecase { CancelRideUsecase(this.repository); - Future> call(int orderId) { + Future> call(int orderId) { return repository.cancelRide(orderId); } } diff --git a/lib/domain/usecase/finish_ride_usecase.dart b/lib/domain/usecase/finish_ride_usecase.dart index edf892a..6a418c8 100644 --- a/lib/domain/usecase/finish_ride_usecase.dart +++ b/lib/domain/usecase/finish_ride_usecase.dart @@ -7,7 +7,7 @@ class FinishRideUsecase { FinishRideUsecase(this.repository); - Future> call(int orderId, List files) { + Future> call(int orderId, List files) { return repository.finishRide(orderId, files); } } \ No newline at end of file diff --git a/lib/domain/usecase/get_notifications_usecase.dart b/lib/domain/usecase/get_notifications_usecase.dart index f67f238..818a461 100644 --- a/lib/domain/usecase/get_notifications_usecase.dart +++ b/lib/domain/usecase/get_notifications_usecase.dart @@ -1,4 +1,5 @@ import '../entities/client_notification.dart'; +import '../entities/pagination_meta.dart'; import '../repositories/notification_repository.dart'; class GetNotificationsUsecase { @@ -6,7 +7,17 @@ class GetNotificationsUsecase { GetNotificationsUsecase(this.repository); - Future> call() { - return repository.getNotifications(); + Future call({int page = 1, int pageSize = 10}) { + return repository.getNotifications(page: page, pageSize: pageSize); } +} + +class NotificationsResult { + final List notifications; + final PaginationMeta pagination; + + NotificationsResult({ + required this.notifications, + required this.pagination, + }); } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5dc56a8..553710f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -57,6 +57,7 @@ class MyApp extends StatelessWidget { BlocProvider(create: (context) => getIt()), BlocProvider(create: (context) => getIt()), BlocProvider(create: (context) => getIt()), + BlocProvider(create: (context) => getIt()), ], child: BlocListener( listener: (context, state) { diff --git a/lib/presentation/components/scooter/scooter_info_section.dart b/lib/presentation/components/scooter/scooter_info_section.dart index fb6e914..a1e3563 100644 --- a/lib/presentation/components/scooter/scooter_info_section.dart +++ b/lib/presentation/components/scooter/scooter_info_section.dart @@ -4,22 +4,81 @@ import 'scooter_info_item.dart'; class ScooterInfoSection extends StatelessWidget { final double distance; final String location; - const ScooterInfoSection({super.key, required this.distance, required this.location}); + final int batteryLevel; + + const ScooterInfoSection({ + super.key, + required this.distance, + required this.location, + required this.batteryLevel, + }); @override Widget build(BuildContext context) { + // 1. Расчет оставшегося расстояния (макс. 62 км) + final double remainingKm = (62 * batteryLevel) / 100; + + // Красиво форматируем: если число целое — убираем точку (например, 31 км вместо 31.0 км) + final String kmText = remainingKm % 1 == 0 + ? '${remainingKm.toInt()} км' + : '${remainingKm.toStringAsFixed(1)} км'; + + // 2. Расчет оставшегося времени (макс. 2.5 часа = 150 минут) + final int totalMinutes = ((150 * batteryLevel) / 100).round(); + final int hours = totalMinutes ~/ 60; + final int minutes = totalMinutes % 60; + + // Формируем строку времени в зависимости от получившихся значений + String timeText = ''; + if (hours > 0 && minutes > 0) { + timeText = '$hours ч $minutes мин'; + } else if (hours > 0) { + timeText = '$hours ч'; + } else { + timeText = '$minutes мин'; + } + + final int walkingMinutes = (distance / 80).round().clamp(1, 999); + final int walkHours = walkingMinutes ~/ 60; + final int walkMins = walkingMinutes % 60; + + String walkingText = ''; + if (walkHours > 0 && walkMins > 0) { + walkingText = '$walkHours ч $walkMins мин'; + } else if (walkHours > 0) { + walkingText = '$walkHours ч'; + } else { + walkingText = '$walkingMinutes мин'; + } + return Column( children: [ - ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'), - ScooterInfoItem(icon: 'assets/icons/speed.png', text: 'max = 25 км/ч'), - ScooterInfoItem(icon: 'assets/icons/location.png', text: location), + // Динамическая строка с зарядом + ScooterInfoItem( + icon: 'assets/icons/bolt.png', + text: '$kmText или $timeText', + ), + ScooterInfoItem( + icon: 'assets/icons/speed.png', + text: 'max = 25 км/ч', + ), + ScooterInfoItem( + icon: 'assets/icons/location.png', + text: location, + ), Row( children: [ - ScooterInfoItem(icon: 'assets/icons/person.png', text: distance.toInt() >= 1000 - ? '${((distance.toInt()) / 1000).toStringAsFixed(1)} km' - : '${distance.toInt()} m',), - SizedBox(width: 16), - ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'), + ScooterInfoItem( + icon: 'assets/icons/person.png', + text: distance.toInt() >= 1000 + ? '${((distance.toInt()) / 1000).toStringAsFixed(1)} km' + : '${distance.toInt()} m', + ), + const SizedBox(width: 16), + ScooterInfoItem( + icon: 'assets/icons/time.png', + text: walkingText, + ), ], ), ], diff --git a/lib/presentation/components/scooter_bottom_sheet.dart b/lib/presentation/components/scooter_bottom_sheet.dart index 5d50591..aabdea3 100644 --- a/lib/presentation/components/scooter_bottom_sheet.dart +++ b/lib/presentation/components/scooter_bottom_sheet.dart @@ -233,7 +233,7 @@ class _ScooterCard extends StatelessWidget { GestureDetector( onTap: () { - context.go('/home/scooter/${scooter.id}', extra: address); + context.go('/home/scooter/${scooter.id}', extra: [address, scooter.distance]); }, child: Container( height: 42, @@ -245,7 +245,7 @@ class _ScooterCard extends StatelessWidget { width: double.infinity, fontSize: 12, onTap: () { - context.go('/home/scooter/${scooter.id}', extra: address); + context.go('/home/scooter/${scooter.id}', extra: [address, scooter.distance]); }, ), ), diff --git a/lib/presentation/components/sheet/current_rides_sheet.dart b/lib/presentation/components/sheet/current_rides_sheet.dart index 5305cea..2dad183 100644 --- a/lib/presentation/components/sheet/current_rides_sheet.dart +++ b/lib/presentation/components/sheet/current_rides_sheet.dart @@ -218,6 +218,9 @@ class _RideCardState extends State<_RideCard> { } else { displayTime = _elapsedTime; } + + final isExpired = isReserved && (_reservationTime - _elapsedTime).isNegative; + final timeString = _formatDuration(displayTime); final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный"; final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa); @@ -347,7 +350,8 @@ class _RideCardState extends State<_RideCard> { height: 32, width: 100, fontSize: 11, - onTap: () { + enabled: !isExpired, + onTap: !isExpired ? () { if (isReserved) { Navigator.pop(context); showModalBottomSheet( @@ -374,7 +378,7 @@ class _RideCardState extends State<_RideCard> { ), ); } - }, + } : null, ), ), ], diff --git a/lib/presentation/components/sheet/payment_method_sheet.dart b/lib/presentation/components/sheet/payment_method_sheet.dart index 30c6062..389432c 100644 --- a/lib/presentation/components/sheet/payment_method_sheet.dart +++ b/lib/presentation/components/sheet/payment_method_sheet.dart @@ -174,7 +174,7 @@ class _PaymentMethodSheetState extends State { if (widget.showBalance) ...[ PaymentOption( title: 'Баланс', - subtitle: '${state.balance.toStringAsFixed(2)} BYN', + subtitle: '${state.balance} баллов', isSelected: _selectedPaymentMethod == -1, onTap: () { setState(() { @@ -192,7 +192,7 @@ class _PaymentMethodSheetState extends State { return Padding( padding: const EdgeInsets.only(bottom: 12), child: PaymentOption( - title: card.isMain ? "Основная" : "", + title: card.isMain ? "Основная" : "Карта", subtitle: '${card.cardLastNumber}', isSelected: _selectedPaymentMethod == index, onTap: () async { diff --git a/lib/presentation/components/sheet/tariff_sheet.dart b/lib/presentation/components/sheet/tariff_sheet.dart index c02ec01..35cf45e 100644 --- a/lib/presentation/components/sheet/tariff_sheet.dart +++ b/lib/presentation/components/sheet/tariff_sheet.dart @@ -12,6 +12,7 @@ import '../../../domain/entities/payment_card.dart'; import '../../../domain/entities/scooter.dart'; import '../../../domain/entities/tariff.dart'; import '../../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../../domain/usecase/get_profile_usecase.dart'; import '../../../domain/usecase/set_main_payment_card_usecase.dart'; import '../../event/payment_method_sheet_event.dart'; import '../../event/tariff_sheet_event.dart'; @@ -40,6 +41,24 @@ class _TariffSheetState extends State { @override Widget build(BuildContext context) { + final double remainingKm = (62 * widget.scooter.batteryLevel) / 100; + + final String kmText = remainingKm % 1 == 0 + ? '${remainingKm.toInt()} км' + : '${remainingKm.toStringAsFixed(1)} км'; + + final int totalMinutes = ((150 * widget.scooter.batteryLevel) / 100).round(); + final int hours = totalMinutes ~/ 60; + final int minutes = totalMinutes % 60; + + String timeText = ''; + if (hours > 0 && minutes > 0) { + timeText = '$hours ч $minutes мин'; + } else if (hours > 0) { + timeText = '$hours ч'; + } else { + timeText = '$minutes мин'; + } return BlocConsumer( listener: (context, state) { if (state.status == TariffSheetStatus.failure && state.errorMessage != null) { @@ -135,7 +154,6 @@ class _TariffSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 🔹 HEADER Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( @@ -227,7 +245,7 @@ class _TariffSheetState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Заряда хватит на 4 часа 17 минут\nили 47 км', + 'Заряда хватит на $kmText или $timeText', style: const TextStyle( color: Colors.white, fontSize: 13, @@ -303,6 +321,7 @@ class _TariffSheetState extends State { create: (context) => PaymentMethodSheetBloc( getIt(), getIt(), + getIt(), )..add(PaymentMethodSheetStarted()), child: PaymentMethodSheet( initialSelectedCard: state.useBalance ? null : state.selectedCard, @@ -547,7 +566,7 @@ class _TariffCard extends StatelessWidget { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => TariffInfoSheet( + builder: (_) => TariffInfoSheet( tariff: tariff, isInsurance: isInsurance, onChanged: (val) { diff --git a/lib/presentation/components/side_menu.dart b/lib/presentation/components/side_menu.dart index f6cb3d1..98e8bfe 100644 --- a/lib/presentation/components/side_menu.dart +++ b/lib/presentation/components/side_menu.dart @@ -63,59 +63,81 @@ class SideMenu extends StatelessWidget { child: SingleChildScrollView( child: Column( children: [ - // Пункты меню Column( children: [ _MenuItem( icon: 'assets/icons/person_icon.png', title: 'Профиль', - onTap: () => { - context.go('/home/profile'), + onTap: () { + Navigator.pop(context); + context.go('/home/profile'); }, ), _MenuItem( icon: 'assets/icons/list_star_icon.png', title: 'История поездок', - onTap: () => context.go("/home/order-history"), + onTap: () { + Navigator.pop(context); + context.go("/home/order-history"); + }, ), const Divider(color: Colors.white30, height: 1), _MenuItem( icon: 'assets/icons/creditcard_icon.png', title: 'Способ оплаты', - onTap: () => context.go("/home/payment-methods"), + onTap: () { + Navigator.pop(context); + context.go("/home/payment-methods"); + }, ), _MenuItem( icon: 'assets/icons/promo_icon.png', title: 'Промокоды', - onTap: () => context.go("/home/promo"), + onTap: () { + Navigator.pop(context); + context.go("/home/promo"); + }, ), _MenuItem( icon: 'assets/icons/plans_icon.png', title: 'Абонементы', - onTap: () => context.push("/home/subscriptions"), + onTap: () { + Navigator.pop(context); + context.push("/home/subscriptions"); + }, ), const Divider(color: Colors.white30, height: 1), _MenuItem( icon: 'assets/icons/magazine_icon.png', title: 'Правила пользования самокатом', - onTap: () => context.go("/home/rules") + onTap: () { + Navigator.pop(context); + context.go("/home/rules"); + } ), _MenuItem( icon: 'assets/icons/headphones_icon.png', title: 'Техподдержка', - onTap: () => { - context.go('/home/support'), + onTap: () { + Navigator.pop(context); + context.go('/home/support'); }, ), _MenuItem( icon: 'assets/icons/doc_icon.png', title: 'Документы', - onTap: () => context.go("/home/documents"), + onTap: () { + Navigator.pop(context); + context.go("/home/documents"); + }, ), _MenuItem( icon: 'assets/icons/news_icon.png', title: 'Новости', - onTap: () => context.go("/home/news"), + onTap: () { + Navigator.pop(context); + context.go("/home/news"); + }, ), const Divider(color: Colors.white30, height: 1), _MenuItem( diff --git a/lib/presentation/event/notifications_event.dart b/lib/presentation/event/notifications_event.dart index b4eb4b1..ac737b8 100644 --- a/lib/presentation/event/notifications_event.dart +++ b/lib/presentation/event/notifications_event.dart @@ -1,3 +1,8 @@ sealed class NotificationsEvent {} -class NotificationsFetchRequested extends NotificationsEvent {} \ No newline at end of file +class NotificationsFetchRequested extends NotificationsEvent { + final int page; + NotificationsFetchRequested({this.page = 1}); +} + +class NotificationsLoadMoreRequested extends NotificationsEvent {} \ No newline at end of file diff --git a/lib/presentation/event/spalsh_event.dart b/lib/presentation/event/spalsh_event.dart index 8e9abca..3874d80 100644 --- a/lib/presentation/event/spalsh_event.dart +++ b/lib/presentation/event/spalsh_event.dart @@ -13,3 +13,5 @@ class AuthCheckRequested extends SplashEvent {} class AuthStarted extends SplashEvent {} class PinVerificationSuccess extends SplashEvent {} + +class AuthLogoutRequested extends SplashEvent {} diff --git a/lib/presentation/navigation/app_router.dart b/lib/presentation/navigation/app_router.dart index 2d83b0e..d446bb1 100644 --- a/lib/presentation/navigation/app_router.dart +++ b/lib/presentation/navigation/app_router.dart @@ -260,8 +260,11 @@ class AppRouter { type: MaterialType.transparency, child: BlocProvider( create: (context) => - PaymentMethodSheetBloc(getIt(), - getIt()), + PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + ), child: PaymentMethodSheet(), ), ), @@ -323,10 +326,16 @@ class AppRouter { GoRoute( path: 'scooter/:id', builder: (context, state) { - String address = state.extra as String; + List extras = []; + if (state.extra != null) { + extras = state.extra as List; + } + String address = extras[0]; + double distance = extras[1]; + return BlocProvider( create: (context) => getIt(), - child: ScooterDetailScreen(address: address), + child: ScooterDetailScreen(address: address, distance: distance), ); } ), @@ -444,19 +453,21 @@ class AppRouter { ), GoRoute( path: 'payment-methods', - builder: (context, state) => - BlocProvider( - create: (context) => - PaymentMethodsBloc( - getIt(), - getIt(), - getIt(), - getIt(), - getIt(), - ) - ..add(PaymentMethodsStarted()), - child: PaymentMethodsScreen(), - ), + builder: (context, state) { + final refreshId = state.uri.queryParameters['refreshId']; + + return BlocProvider( + key: refreshId != null ? ValueKey(refreshId) : null, + create: (context) => PaymentMethodsBloc( + getIt(), + getIt(), + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodsStarted()), + child: const PaymentMethodsScreen(), + ); + }, routes: [ GoRoute( path: 'add-card', @@ -480,6 +491,28 @@ class AppRouter { ..add(LoadTopUpData()), child: TopUpScreen(), ), + routes: [ + GoRoute( + path: 'result/:status', + builder: (context, state) { + final modeParam = state.pathParameters['status']; + + CardBindingStatus status = CardBindingStatus.success; + String title = 'Успешно!'; + + if (modeParam == 'failed') { + status = CardBindingStatus.failed; + title = 'Извините, что-то\nпошло не так'; + } + + return CardBindingResultScreen( + status: status, + title: title, + cardImagePath: 'assets/icons/card_result_screen.png', + ); + }, + ), + ], ), ], ), @@ -524,45 +557,59 @@ class AppRouter { print(splashBloc.state); print(state.uri.toString()); - if (authState is AuthInitial && currentLocation != '/splash') { - print("splash"); - return '/splash'; + if (authState is AuthInitial) { + if (currentLocation != '/splash') return '/splash'; + return null; } - if (authState is AuthFirstLaunch && - currentLocation != '/login' && - currentLocation != '/privacy-policy' && - currentLocation != '/license-agreement') { - print("login"); - return '/login'; + // 2. Первый запуск или пользователь не авторизован + if (authState is AuthFirstLaunch || authState is AuthUnauthenticated) { + final isAllowed = currentLocation == '/login' || + currentLocation.startsWith('/verify') || + currentLocation == '/privacy-policy' || + currentLocation == '/license-agreement'; + + if (!isAllowed) return '/login'; + return null; } - if (authState is AuthUnauthenticated && - currentLocation != '/login' && - currentLocation != '/privacy-policy' && - currentLocation != '/license-agreement') { - print("login2"); - return '/login'; + // 3. Авторизация в процессе (переход от ввода телефона/SMS к PIN-коду) + if (authState is AuthInProgress) { + final isAllowed = currentLocation == '/login' || + currentLocation.startsWith('/verify') || + currentLocation == '/pin' || // ← Теперь роутер пустит сюда! + currentLocation == '/privacy-policy' || + currentLocation == '/license-agreement'; + + if (!isAllowed) return '/login'; + return null; } + // 4. Пользователь авторизован, но PIN еще не проверен if (authState is AuthAuthenticated) { - final isComingFromStart = - currentLocation == '/splash' || - currentLocation == '/login' || - currentLocation == '/phone'; + final isComingFromStart = currentLocation == '/splash' || + currentLocation == '/login' || + currentLocation.startsWith('/verify') || + currentLocation == '/phone'; if (isComingFromStart) { print("redirecting to pin check"); return '/pin'; } + return null; } + // 5. Пользователь полностью авторизован и PIN успешно верифицирован if (authState is AuthPinVerified) { - if (currentLocation == '/splash' || + final isComingFromAuth = currentLocation == '/splash' || currentLocation == '/login' || - currentLocation == '/pin') { + currentLocation.startsWith('/verify') || + currentLocation == '/pin'; + + if (isComingFromAuth) { return '/home'; } + return null; } return null; diff --git a/lib/presentation/screens/card_binding_result_screen.dart b/lib/presentation/screens/card_binding_result_screen.dart index becb7f5..64c2804 100644 --- a/lib/presentation/screens/card_binding_result_screen.dart +++ b/lib/presentation/screens/card_binding_result_screen.dart @@ -31,7 +31,8 @@ class _CardBindingResultScreenState extends State { if (widget.status == CardBindingStatus.success) { _autoPopTimer = Timer(const Duration(seconds: 5), () { if (mounted) { - context.go('/home/payment-methods'); + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); } }); } @@ -125,7 +126,10 @@ class _CardBindingResultScreenState extends State { GradientButton( text: "Попробовать снова", - onTap: () {}, + onTap: () { + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); + }, enabled: true, showArrows: true, ), @@ -135,7 +139,11 @@ class _CardBindingResultScreenState extends State { width: double.infinity, height: 56, child: TextButton( - onPressed: () => context.go("/home/payment-methods"), + onPressed: () + { + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); + }, style: TextButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(28), diff --git a/lib/presentation/screens/edit_profile_screen.dart b/lib/presentation/screens/edit_profile_screen.dart index 7cb9e2b..7541c80 100644 --- a/lib/presentation/screens/edit_profile_screen.dart +++ b/lib/presentation/screens/edit_profile_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/app_colors.dart'; +import '../../di/service_locator.dart'; import '../../domain/entities/user_profile.dart'; import '../components/custom_app_bar.dart'; import '../components/gradient_button.dart'; @@ -106,7 +107,7 @@ class _EditProfileScreenState extends State { } if (state.isSuccess) { - context.read().add(ProfileUpdated()); + getIt().add(ProfileUpdated()); context.pop(); } if (state.error != null) { diff --git a/lib/presentation/screens/map_screen.dart b/lib/presentation/screens/map_screen.dart index a6956ac..9244a48 100644 --- a/lib/presentation/screens/map_screen.dart +++ b/lib/presentation/screens/map_screen.dart @@ -4,24 +4,9 @@ import 'dart:ui' as ui; import 'package:bot_toast/bot_toast.dart'; import 'package:be_happy/domain/entities/client_notification.dart'; -import 'package:be_happy/domain/usecase/book_scooter_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_payment_cards_usecase.dart'; -import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; -import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; -import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart'; import 'package:be_happy/presentation/components/fine_notification_card.dart'; import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart'; import 'package:be_happy/presentation/components/payment_notification_card.dart'; -import 'package:be_happy/presentation/components/sheet/current_rides_sheet.dart'; -import 'package:be_happy/presentation/components/sheet/map_settings_sheet.dart'; -import 'package:be_happy/presentation/components/sheet/tariff_sheet.dart'; -import 'package:be_happy/presentation/event/current_rides_event.dart'; -import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; -import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart'; -import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; -import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; @@ -32,18 +17,12 @@ import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/scooter.dart'; import '../../domain/entities/zone.dart'; -import '../../domain/usecase/get_address_by_point_usecase.dart'; -import '../../domain/usecase/get_available_tariffs_usecase.dart'; import '../../domain/usecase/get_notifications_stream_usecase.dart'; -import '../components/notification_toast.dart'; -import '../components/sheet/scooter_bottom_sheet.dart'; import '../components/side_menu.dart'; import '../components/unpaid_order_notification_card.dart'; import '../event/map_event.dart'; -import '../event/scooter_detail_modal_event.dart'; import '../state/map_state.dart'; import '../viewmodel/map_bloc.dart'; -import '../viewmodel/scooter_detail_modal_bloc.dart'; class MapScreen extends StatefulWidget { const MapScreen({super.key}); @@ -609,28 +588,33 @@ class _MapScreenState extends State { if (zones == null || zones.isEmpty) return []; List objects = []; - - List allZoneHoles = []; + List driveOuterRings = []; for (var zone in zones) { - var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); + if (zone.type == "Drive") { + for (var polygonData in zone.geometry) { + if (polygonData.isEmpty) continue; - var cleanPoints = []; - for (var p in points) { - if (cleanPoints.isEmpty || cleanPoints.last != p) { - cleanPoints.add(p); + var outerPoints = polygonData[0].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); + + var cleanPoints = []; + for (var p in outerPoints) { + if (cleanPoints.isEmpty || cleanPoints.last != p) { + cleanPoints.add(p); + } + } + + if (cleanPoints.length > 2) { + if (cleanPoints.first != cleanPoints.last) { + cleanPoints.add(cleanPoints.first); + } + driveOuterRings.add(LinearRing(points: cleanPoints)); + } } } - - if (cleanPoints.length > 2) { - if (cleanPoints.first != cleanPoints.last) { - cleanPoints.add(cleanPoints.first); - } - - allZoneHoles.add(LinearRing(points: cleanPoints)); - } } + // Добавляем глобальную маску (все, что НЕ Drive — затеняется красным) objects.add( PolygonMapObject( mapId: const MapObjectId('global_inverse_mask'), @@ -641,7 +625,7 @@ class _MapScreenState extends State { Point(latitude: -85, longitude: 179.9), Point(latitude: -85, longitude: -179.9), ]), - innerRings: allZoneHoles, + innerRings: driveOuterRings, // Вырезаем только зоны Drive ), strokeWidth: 0, fillColor: Colors.red.withOpacity(0.15), @@ -649,27 +633,63 @@ class _MapScreenState extends State { ), ); + // 2. Отрисовываем каждую зону (включая MultiPolygon и вложенные NotDrive/Finish) for (var zone in zones) { Color borderColor; + Color fillColor; + + // Стилизация в зависимости от типа зоны if (zone.type == "Drive") { - borderColor = const Color(0xFF5ECD4C); + borderColor = const Color(0xFF5ECD4C); // Зеленый бордюр + fillColor = Colors.transparent; // Прозрачная внутри маски } else if (zone.type == "NotDrive") { - borderColor = const Color(0xFFEF4444); + borderColor = const Color(0xFFEF4444); // Красный бордюр + fillColor = const Color(0xFFEF4444).withOpacity(0.3); // Красная полупрозрачная заливка для наглядности внутри Drive } else { - borderColor = const Color(0xFFA78BFA); + borderColor = const Color(0xFFA78BFA); // Фиолетовый бордюр (Парковка) + fillColor = const Color(0xFFA78BFA).withOpacity(0.25); // Фиолетовая заливка } - objects.add( - PolylineMapObject( - mapId: MapObjectId('zone_contour_${zone.id}'), - polyline: Polyline( - points: zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(), + int polyIndex = 0; + for (var polygonData in zone.geometry) { + if (polygonData.isEmpty) continue; + + // Строим внешнее кольцо текущего полигона + var outerPoints = polygonData[0].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); + if (outerPoints.first != outerPoints.last) { + outerPoints.add(outerPoints.first); + } + LinearRing outerRing = LinearRing(points: outerPoints); + + // Строим внутренние кольца (дыры), если они есть у этого полигона + List innerRings = []; + for (int i = 1; i < polygonData.length; i++) { + var innerPoints = polygonData[i].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); + if (innerPoints.isNotEmpty) { + if (innerPoints.first != innerPoints.last) { + innerPoints.add(innerPoints.first); + } + innerRings.add(LinearRing(points: innerPoints)); + } + } + + // Рендерим полноценный PolygonMapObject (он поддерживает и заливку, и обводку) + objects.add( + PolygonMapObject( + mapId: MapObjectId('zone_poly_${zone.type}_${zone.id}_$polyIndex'), + polygon: Polygon( + outerRing: outerRing, + innerRings: innerRings, + ), + strokeColor: borderColor, + strokeWidth: 2.0, + fillColor: fillColor, + // Поднимаем зоны ограничений и парковок выше маски и контуров Drive + zIndex: zone.type == "Drive" ? 1 : 2, ), - strokeColor: borderColor, - strokeWidth: 2.0, - zIndex: 1, - ), - ); + ); + polyIndex++; + } } return objects; @@ -684,7 +704,10 @@ class _MapScreenState extends State { child: Builder( builder: (innerContext) => _RoundIconButton( icon: Icons.menu, - onPressed: () => {Scaffold.of(innerContext).openDrawer()}, + onPressed: () { + context.read().add(FetchProfileData()); + Scaffold.of(innerContext).openDrawer(); + }, ), ), ), diff --git a/lib/presentation/screens/notifications_screen.dart b/lib/presentation/screens/notifications_screen.dart index d3777d0..ed2e60e 100644 --- a/lib/presentation/screens/notifications_screen.dart +++ b/lib/presentation/screens/notifications_screen.dart @@ -23,7 +23,10 @@ class NotificationsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return _NotificationsScreenContent(); + return BlocProvider( + create: (context) => getIt()..add(NotificationsFetchRequested(page: 1)), + child: _NotificationsScreenContent(), + ); } } @@ -36,21 +39,31 @@ class _NotificationsScreenContent extends StatefulWidget { class _NotificationsScreenContentState extends State<_NotificationsScreenContent> { NotificationFilter _filter = NotificationFilter.all; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } void _setFilter(NotificationFilter filter) { - setState(() { - _filter = filter; - }); + setState(() => _filter = filter); + _scrollController.jumpTo(0); + context.read().add(NotificationsFetchRequested(page: 1)); } @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt()..add(NotificationsFetchRequested()), - child: NotificationsView( - filter: _filter, - onFilterChanged: _setFilter, - ), + return NotificationsView( + filter: _filter, + onFilterChanged: _setFilter, + scrollController: _scrollController, ); } } @@ -58,11 +71,13 @@ class _NotificationsScreenContentState extends State<_NotificationsScreenContent class NotificationsView extends StatelessWidget { final NotificationFilter filter; final ValueChanged onFilterChanged; + final ScrollController? scrollController; const NotificationsView({ super.key, this.filter = NotificationFilter.all, required this.onFilterChanged, + this.scrollController, }); @override @@ -119,7 +134,7 @@ class NotificationsView extends StatelessWidget { const SizedBox(height: 24), ElevatedButton( onPressed: () { - context.read().add(NotificationsFetchRequested()); + context.read().add(NotificationsFetchRequested(page: 1)); }, child: const Text('Повторить'), ), @@ -138,9 +153,6 @@ class NotificationsView extends StatelessWidget { return n.category == NotificationCategory.payment; case NotificationFilter.order: return n.category == NotificationCategory.scooter; - // || n.category == NotificationCategory.adminInfo - // || n.category == NotificationCategory.companyInfo; - default: return true; } @@ -152,14 +164,39 @@ class NotificationsView extends StatelessWidget { return RefreshIndicator( onRefresh: () async { - context.read().add(NotificationsFetchRequested()); + context.read().add(NotificationsFetchRequested(page: 1)); }, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: filtered.length, - itemBuilder: (context, index) { - return _NotificationCard(notification: filtered[index]); + child: NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 50 && + scrollInfo.metrics.maxScrollExtent > 0 && + state.hasMore && + !state.isLoadingMore && + state.status == NotificationsStatus.success) { + context.read().add(NotificationsLoadMoreRequested()); + } + return false; }, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: filtered.length + (state.hasMore && filtered.length >= 10 ? 1 : 0), + itemBuilder: (context, index) { + if (index == filtered.length && state.hasMore && filtered.length >= 10) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ), + ), + ); + } + return _NotificationCard(notification: filtered[index]); + }, + ), ), ); }, @@ -308,16 +345,23 @@ class _NotificationCard extends StatelessWidget { } String _formatDate(DateTime date) { + final localDate = date.toLocal(); final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays == 0) { - return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } else if (difference.inDays == 1) { - return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } else { - return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}'; + if (localDate.year == now.year && + localDate.month == now.month && + localDate.day == now.day) { + return 'Сегодня, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}'; } + + final yesterday = now.subtract(const Duration(days: 1)); + if (localDate.year == yesterday.year && + localDate.month == yesterday.month && + localDate.day == yesterday.day) { + return 'Вчера, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}'; + } + + return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}'; } String _getTypeLabel(NotificationType type) { diff --git a/lib/presentation/screens/payment_confirm_screen.dart b/lib/presentation/screens/payment_confirm_screen.dart index eeeedcf..84c3d3f 100644 --- a/lib/presentation/screens/payment_confirm_screen.dart +++ b/lib/presentation/screens/payment_confirm_screen.dart @@ -5,6 +5,7 @@ import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../components/custom_app_bar.dart'; import '../components/gradient_button.dart'; @@ -134,11 +135,6 @@ class _PaymentConfirmScreenContent extends StatelessWidget { const SizedBox(height: 24), - _buildInfoLabel('Начислено баллов:'), - _buildValueRow('points_icon.png', '2 балла'), - - const SizedBox(height: 24), - Row( children: [ Expanded( @@ -149,7 +145,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { _buildInfoLabel('Стоимость поездки:'), _buildValueRow( 'money_icon.png', - '17,17 BYN', + order?.totalOrderPricePrint ?? '- BYN', ), ], ), @@ -162,7 +158,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { _buildInfoLabel('Оплачено:'), _buildValueRow( 'money_icon.png', - '10,00 BYN', + order?.payTotalPricePrint ?? '0,00 BYN', ), ], ), @@ -193,9 +189,9 @@ class _PaymentConfirmScreenContent extends StatelessWidget { width: 40, ), const SizedBox(width: 12), - const Text( - '7,17 BYN', - style: TextStyle( + Text( + order?.unpayTotalPricePrint ?? '0,00 BYN', + style: const TextStyle( color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold, @@ -227,6 +223,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { create: (context) => PaymentMethodSheetBloc( getIt(), getIt(), + getIt(), )..add(PaymentMethodSheetStarted()), child: PaymentMethodSheet( initialSelectedCard: state.useBalance ? null : state.selectedCard, diff --git a/lib/presentation/screens/payment_methods_screen.dart b/lib/presentation/screens/payment_methods_screen.dart index 7894919..5693a53 100644 --- a/lib/presentation/screens/payment_methods_screen.dart +++ b/lib/presentation/screens/payment_methods_screen.dart @@ -80,7 +80,7 @@ class PaymentMethodsScreen extends StatelessWidget { ); } - Widget _buildBalanceCard(BuildContext context, int balance) { + Widget _buildBalanceCard(BuildContext context, double balance) { return Container( padding: EdgeInsets.all(20), decoration: BoxDecoration( diff --git a/lib/presentation/screens/phone_login_screen.dart b/lib/presentation/screens/phone_login_screen.dart index d0a13f6..e6d617f 100644 --- a/lib/presentation/screens/phone_login_screen.dart +++ b/lib/presentation/screens/phone_login_screen.dart @@ -77,7 +77,7 @@ class _PhoneLoginScreenState extends State { return BlocConsumer( listener: (context, state) { if (state.isSuccess) { - context.go("/pin"); + } else if (state.error != null) { setState(() { _isFirstTry = false; }); codeController.clear(); diff --git a/lib/presentation/screens/scooter_detail_screen.dart b/lib/presentation/screens/scooter_detail_screen.dart index deeeed0..ec8df7e 100644 --- a/lib/presentation/screens/scooter_detail_screen.dart +++ b/lib/presentation/screens/scooter_detail_screen.dart @@ -13,7 +13,8 @@ import '../viewmodel/scooter_detail_bloc.dart'; class ScooterDetailScreen extends StatelessWidget { final String address; - const ScooterDetailScreen({super.key, required this.address}); + final double distance; + const ScooterDetailScreen({super.key, required this.address, required this.distance}); @override Widget build(BuildContext context) { @@ -84,7 +85,7 @@ class ScooterDetailScreen extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: ScooterInfoSection(distance: scooter?.distance ?? 0, location: address)), + Expanded(child: ScooterInfoSection(distance: distance, location: address, batteryLevel: scooter?.batteryLevel ?? 0,)), const SizedBox(width: 20), ], ), diff --git a/lib/presentation/screens/subscription_details_screen.dart b/lib/presentation/screens/subscription_details_screen.dart index 20d54e2..e881388 100644 --- a/lib/presentation/screens/subscription_details_screen.dart +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -6,6 +6,7 @@ import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../components/app_checkbox.dart'; import '../components/custom_app_bar.dart'; @@ -170,6 +171,7 @@ class SubscriptionDetailsScreen extends StatelessWidget { create: (context) => PaymentMethodSheetBloc( getIt(), getIt(), + getIt(), )..add(PaymentMethodSheetStarted()), child: const PaymentMethodSheet(showBalance: false), ), diff --git a/lib/presentation/screens/top_up_screen.dart b/lib/presentation/screens/top_up_screen.dart index bcd5e50..916e98b 100644 --- a/lib/presentation/screens/top_up_screen.dart +++ b/lib/presentation/screens/top_up_screen.dart @@ -1,84 +1,108 @@ +import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:path/path.dart'; import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../components/app_checkbox.dart'; -import '../components/custom_app_bar.dart'; // ✅ Уже есть +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/payment_method_sheet_event.dart'; +import '../event/payment_methods_event.dart'; import '../event/top_up_event.dart'; import '../state/top_up_state.dart'; import '../viewmodel/payment_method_sheet_bloc.dart'; import '../viewmodel/top_up_bloc.dart'; class TopUpScreen extends StatelessWidget { + const TopUpScreen({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { return Scaffold( body: Container( - decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: BlocListener( + listener: (context, state) { + if (state is TopUpPaymentSuccess) { + context.go('/home/payment-methods/top-up/result/success'); + } else if (state is TopUpPaymentFailure) { + context.go('/home/payment-methods/top-up/result/failed'); + } + }, child: Column( children: [ const SizedBox(height: 16), const CustomAppBar(title: 'Пополнение баланса'), const SizedBox(height: 20), - BlocBuilder( builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); + if (state is TopUpLoading) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); } - return Expanded( - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTariffList(state, context), - const SizedBox(height: 30), - _buildPriceInfo(state), - const SizedBox(height: 40), - const Text( - 'Способ оплаты', - style: TextStyle(color: Colors.white70), - ), - const SizedBox(height: 15), - _buildCardSelector(state, context), - const SizedBox(height: 20), - _buildAgreement(state, context), - const Spacer(), - _buildPayButton(state, () { - context.read().add(TopUpButtonPressed()); - }), - const SizedBox(height: 30), - ], - ), - ], - ), - ); + if (state is TopUpLoaded) { + if (state.isProcessingPayment) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } + + return Expanded( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTariffList(state, context), + const SizedBox(height: 30), + _buildPriceInfo(state), + const SizedBox(height: 40), + const Text( + 'Способ оплаты', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 15), + _buildCardSelector(state, context), + const SizedBox(height: 20), + _buildAgreement(state, context), + const Spacer(), + _buildPayButton(state, () { + context.read().add(TopUpButtonPressed()); + }), + const SizedBox(height: 30), + ], + ), + ], + ), + ); + } + + return const SizedBox.shrink(); }, ), ], ), ), - ) + ), + ), ), ); } - Widget _buildTariffList(TopUpState state, BuildContext context) { + Widget _buildTariffList(TopUpLoaded state, BuildContext context) { return SizedBox( height: 120, child: ListView.separated( @@ -89,14 +113,11 @@ class TopUpScreen extends StatelessWidget { final tariff = state.certificates[index]; final isSelected = state.selectedTariff == tariff; return GestureDetector( - onTap: () => - context.read().add(SelectCertificate(tariff)), + onTap: () => context.read().add(SelectCertificate(tariff)), child: Container( width: 140, decoration: BoxDecoration( - color: isSelected - ? const Color(0xFF80FFC1) - : Colors.white.withOpacity(0.1), + color: isSelected ? const Color(0xFF80FFC1) : Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white24), ), @@ -127,7 +148,7 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildPriceInfo(TopUpState state) { + Widget _buildPriceInfo(TopUpLoaded state) { if (state.selectedTariff == null) return const SizedBox.shrink(); return Center( child: Column( @@ -157,7 +178,7 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildCardSelector(TopUpState state, BuildContext context) { + Widget _buildCardSelector(TopUpLoaded state, BuildContext context) { return state.selectedCard != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 20), @@ -170,16 +191,14 @@ class TopUpScreen extends StatelessWidget { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (innerContext) => - BlocProvider( - create: (context) => - PaymentMethodSheetBloc( - getIt(), - getIt(), - ) - ..add(PaymentMethodSheetStarted()), - child: const PaymentMethodSheet(showBalance: false,), - ), + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(showBalance: false), + ), ); if (selectedCard != null) { @@ -210,7 +229,6 @@ class TopUpScreen extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( 'Способ оплаты', style: TextStyle( @@ -220,21 +238,9 @@ class TopUpScreen extends StatelessWidget { ), ), const SizedBox(width: 8), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.6), - ), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.4), - ), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.2), - ), + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.6)), + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.4)), + Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.2)), ], ), ), @@ -244,20 +250,19 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildAgreement(TopUpState state, BuildContext context) { + Widget _buildAgreement(TopUpLoaded state, BuildContext context) { return Row( children: [ AppCheckbox( value: state.isAgreed, - onChanged: (v) => - context.read().add(ToggleAgreement(v ?? false)), + onChanged: (v) => context.read().add(ToggleAgreement(v ?? false)), ), const SizedBox(width: 12), - Flexible( + const Flexible( child: Text.rich( TextSpan( - text: 'Я принимаю условия покупки бонусного пакета, при котором денежные средства не подлежат возврату... ', - style: const TextStyle( + text: 'Я принимаю условия покупки бонусного пакека, при котором денежные средства не подлежат возврату... ', + style: TextStyle( color: AppColors.white70, fontSize: 12, ), @@ -268,7 +273,7 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildPayButton(TopUpState state, VoidCallback onTap) { + Widget _buildPayButton(TopUpLoaded state, VoidCallback onTap) { return GradientButton( text: 'Оплатить', onTap: state.isAgreed ? onTap : null, @@ -279,5 +284,4 @@ class TopUpScreen extends StatelessWidget { fontSize: 16, ); } - } \ No newline at end of file diff --git a/lib/presentation/state/map_state.dart b/lib/presentation/state/map_state.dart index 1c2a72e..ec0c9a5 100644 --- a/lib/presentation/state/map_state.dart +++ b/lib/presentation/state/map_state.dart @@ -19,7 +19,7 @@ class ScooterState { final String? address; final String? errorMessage; final String phoneNumber; - final int balance; + final double balance; final UserCheckFlags flags; final ClientNotification? lastNotification; @@ -52,7 +52,7 @@ class ScooterState { String? address, String? errorMessage, String? phoneNumber, - int? balance, + double? balance, UserCheckFlags? flags, ClientNotification? lastNotification, }) { diff --git a/lib/presentation/state/notifications_state.dart b/lib/presentation/state/notifications_state.dart index ea5728f..20cc561 100644 --- a/lib/presentation/state/notifications_state.dart +++ b/lib/presentation/state/notifications_state.dart @@ -1,27 +1,43 @@ import '../../domain/entities/client_notification.dart'; -enum NotificationsStatus { initial, loading, success, failure } +enum NotificationsStatus { initial, loading, loadingMore, success, failure } class NotificationsState { final NotificationsStatus status; final List notifications; final String? errorMessage; + final int currentPage; + final int totalPages; + final bool hasMore; + final bool isLoadingMore; const NotificationsState({ this.status = NotificationsStatus.initial, this.notifications = const [], this.errorMessage, + this.currentPage = 1, + this.totalPages = 1, + this.hasMore = true, + this.isLoadingMore = false, }); NotificationsState copyWith({ NotificationsStatus? status, List? notifications, String? errorMessage, + int? currentPage, + int? totalPages, + bool? hasMore, + bool? isLoadingMore, }) { return NotificationsState( status: status ?? this.status, notifications: notifications ?? this.notifications, errorMessage: errorMessage ?? this.errorMessage, + currentPage: currentPage ?? this.currentPage, + totalPages: totalPages ?? this.totalPages, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, ); } } \ No newline at end of file diff --git a/lib/presentation/state/payment_confirm_state.dart b/lib/presentation/state/payment_confirm_state.dart index 2164595..eaa8c22 100644 --- a/lib/presentation/state/payment_confirm_state.dart +++ b/lib/presentation/state/payment_confirm_state.dart @@ -11,7 +11,7 @@ class PaymentConfirmState { final bool paymentCompleted; final ScooterOrder? order; final bool useBalance; - final int userBalance; + final double userBalance; const PaymentConfirmState({ this.status = PaymentConfirmStatus.initial, @@ -30,7 +30,7 @@ class PaymentConfirmState { bool? paymentCompleted, ScooterOrder? order, bool? useBalance, - int? userBalance, + double? userBalance, }) { return PaymentConfirmState( status: status ?? this.status, diff --git a/lib/presentation/state/payment_method_sheet_state.dart b/lib/presentation/state/payment_method_sheet_state.dart index 66291a7..abf5352 100644 --- a/lib/presentation/state/payment_method_sheet_state.dart +++ b/lib/presentation/state/payment_method_sheet_state.dart @@ -11,7 +11,7 @@ class PaymentMethodSheetState { PaymentMethodSheetState({ this.status = PaymentMethodSheetStatus.initial, this.cards = const [], - this.balance = 0.0, + this.balance = 0, this.errorMessage, }); diff --git a/lib/presentation/state/payment_methods_state.dart b/lib/presentation/state/payment_methods_state.dart index 993d86f..70e7355 100644 --- a/lib/presentation/state/payment_methods_state.dart +++ b/lib/presentation/state/payment_methods_state.dart @@ -5,7 +5,7 @@ enum PaymentMethodsStatus { initial, loading, success, failure } class PaymentMethodsState { final PaymentMethodsStatus status; final List cards; - final int balance; + final double balance; final String? errorMessage; final bool isDeleting; final bool isSettingMain; @@ -22,7 +22,7 @@ class PaymentMethodsState { PaymentMethodsState copyWith({ PaymentMethodsStatus? status, List? cards, - int? balance, + double? balance, String? errorMessage, bool? isDeleting, bool? isSettingMain, diff --git a/lib/presentation/state/tariff_sheet_state.dart b/lib/presentation/state/tariff_sheet_state.dart index e0a3787..60f8b49 100644 --- a/lib/presentation/state/tariff_sheet_state.dart +++ b/lib/presentation/state/tariff_sheet_state.dart @@ -10,7 +10,7 @@ class TariffSheetState { final String? errorMessage; final PaymentCard? selectedCard; final bool isInsurance; - final int userBalance; + final double userBalance; final bool useBalance ; TariffSheetState({ @@ -29,7 +29,7 @@ class TariffSheetState { PaymentCard? selectedCard, String? errorMessage, bool? isInsurance, - int? userBalance, + double? userBalance, bool? useBalance, }) => TariffSheetState( diff --git a/lib/presentation/state/top_up_state.dart b/lib/presentation/state/top_up_state.dart index 32af0e0..ff21a60 100644 --- a/lib/presentation/state/top_up_state.dart +++ b/lib/presentation/state/top_up_state.dart @@ -1,40 +1,55 @@ import 'package:be_happy/domain/entities/certificate.dart'; import 'package:be_happy/domain/entities/payment_card.dart'; -import '../../domain/entities/top_up_tariff.dart'; +sealed class TopUpState { + const TopUpState(); +} -class TopUpState { +class TopUpLoading extends TopUpState { + const TopUpLoading(); +} + +class TopUpLoaded extends TopUpState { final List certificates; final List cards; final Certificate? selectedTariff; final PaymentCard? selectedCard; - final bool isLoading; final bool isAgreed; + final bool isProcessingPayment; - TopUpState({ + const TopUpLoaded({ this.certificates = const [], this.cards = const [], this.selectedTariff, this.selectedCard, - this.isLoading = false, this.isAgreed = false, + this.isProcessingPayment = false, }); - TopUpState copyWith({ + TopUpLoaded copyWith({ List? certificates, List? cards, Certificate? selectedTariff, PaymentCard? selectedCard, - bool? isLoading, bool? isAgreed, + bool? isProcessingPayment, }) { - return TopUpState( + return TopUpLoaded( certificates: certificates ?? this.certificates, cards: cards ?? this.cards, selectedTariff: selectedTariff ?? this.selectedTariff, selectedCard: selectedCard ?? this.selectedCard, - isLoading: isLoading ?? this.isLoading, isAgreed: isAgreed ?? this.isAgreed, + isProcessingPayment: isProcessingPayment ?? this.isProcessingPayment, ); } +} + +class TopUpPaymentSuccess extends TopUpState { + const TopUpPaymentSuccess(); +} + +class TopUpPaymentFailure extends TopUpState { + final String errorMessage; + const TopUpPaymentFailure(this.errorMessage); } \ No newline at end of file diff --git a/lib/presentation/viewmodel/auth_bloc.dart b/lib/presentation/viewmodel/auth_bloc.dart index afbc67e..1b9f756 100644 --- a/lib/presentation/viewmodel/auth_bloc.dart +++ b/lib/presentation/viewmodel/auth_bloc.dart @@ -8,7 +8,11 @@ class PhoneAuthBloc extends Bloc { PhoneAuthBloc(this._loginUseCase) : super(PhoneAuthState.initial()) { on((event, emit) { - emit(state.copyWith(phone: event.phone)); + emit(state.copyWith( + phone: event.phone, + isSuccess: false, + error: null, + )); }); on((event, emit) { diff --git a/lib/presentation/viewmodel/map_bloc.dart b/lib/presentation/viewmodel/map_bloc.dart index e8bf450..e039989 100644 --- a/lib/presentation/viewmodel/map_bloc.dart +++ b/lib/presentation/viewmodel/map_bloc.dart @@ -267,7 +267,7 @@ class MapBloc extends Bloc { ) async { await logoutUseCase(); stopNotificationStream(); - splashBloc.add(AuthCheckRequested()); + splashBloc.add(AuthLogoutRequested()); } FutureOr _onCheckUser( diff --git a/lib/presentation/viewmodel/notifications_bloc.dart b/lib/presentation/viewmodel/notifications_bloc.dart index 1bcec78..f56c45e 100644 --- a/lib/presentation/viewmodel/notifications_bloc.dart +++ b/lib/presentation/viewmodel/notifications_bloc.dart @@ -9,26 +9,53 @@ class NotificationsBloc extends Bloc { NotificationsBloc(this._getNotificationsUsecase) : super(const NotificationsState()) { on(_onFetchRequested); + on(_onLoadMoreRequested); } Future _onFetchRequested( NotificationsFetchRequested event, Emitter emit, ) async { - emit(state.copyWith(status: NotificationsStatus.loading)); + if (event.page == 1) { + emit(state.copyWith(status: NotificationsStatus.loading)); + } try { - final notifications = await _getNotificationsUsecase(); + final result = await _getNotificationsUsecase( + page: event.page, + pageSize: 100, + ); + + final newNotifications = event.page == 1 + ? result.notifications + : [...state.notifications, ...result.notifications]; + emit(state.copyWith( status: NotificationsStatus.success, - notifications: notifications, + notifications: newNotifications, + currentPage: result.pagination.currentPage, + totalPages: result.pagination.lastPage, + hasMore: result.pagination.currentPage < result.pagination.lastPage, + isLoadingMore: false, )); } catch (e) { dev.log('NotificationsBloc: Ошибка загрузки: $e'); emit(state.copyWith( - status: NotificationsStatus.failure, - errorMessage: e.toString(), + status: event.page == 1 ? NotificationsStatus.failure : NotificationsStatus.success, + errorMessage: event.page == 1 ? e.toString() : null, + isLoadingMore: false, )); } } + + Future _onLoadMoreRequested( + NotificationsLoadMoreRequested event, + Emitter emit, + ) async { + if (!state.hasMore || state.isLoadingMore) return; + + emit(state.copyWith(isLoadingMore: true)); + final nextPage = state.currentPage + 1; + add(NotificationsFetchRequested(page: nextPage)); + } } \ No newline at end of file diff --git a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart index 97e09f3..c998cab 100644 --- a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart +++ b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart @@ -3,15 +3,17 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../core/result.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/get_profile_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../event/payment_method_sheet_event.dart'; import '../state/payment_method_sheet_state.dart'; class PaymentMethodSheetBloc extends Bloc { final GetPaymentCardsUsecase _getPaymentCardsUsecase; + final GetProfileUseCase _getProfileUseCase; final SetMainPaymentCardUsecase _setMainPaymentCardUsecase; - PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase) + PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase, this._getProfileUseCase) : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { on(_onStarted); } @@ -24,10 +26,13 @@ class PaymentMethodSheetBloc extends Bloc>) { emit(state.copyWith( status: PaymentMethodSheetStatus.success, + balance: profile.balance, cards: result.data ?? [], )); } else { diff --git a/lib/presentation/viewmodel/payment_methods_bloc.dart b/lib/presentation/viewmodel/payment_methods_bloc.dart index 861ac01..53ba9cc 100644 --- a/lib/presentation/viewmodel/payment_methods_bloc.dart +++ b/lib/presentation/viewmodel/payment_methods_bloc.dart @@ -46,6 +46,8 @@ class PaymentMethodsBloc extends Bloc if (result is Success>) { + + print("BALANCE: ${profile.balance} BYN"); emit(state.copyWith( status: PaymentMethodsStatus.success, cards: result.data ?? [], diff --git a/lib/presentation/viewmodel/reserved_ride_bloc.dart b/lib/presentation/viewmodel/reserved_ride_bloc.dart index 76c7679..2e9310a 100644 --- a/lib/presentation/viewmodel/reserved_ride_bloc.dart +++ b/lib/presentation/viewmodel/reserved_ride_bloc.dart @@ -48,7 +48,7 @@ class ReservedRideBloc extends Bloc { final result = await _cancelRideUsecase(event.orderId); - if (result is Success) { + if (result is Success) { emit(state.copyWith( status: ReservedRideStatus.success, rideCancelled: true, diff --git a/lib/presentation/viewmodel/send_photo_bloc.dart b/lib/presentation/viewmodel/send_photo_bloc.dart index 1893b8b..37a042a 100644 --- a/lib/presentation/viewmodel/send_photo_bloc.dart +++ b/lib/presentation/viewmodel/send_photo_bloc.dart @@ -40,7 +40,6 @@ class SendPhotoBloc extends Bloc { switch (result) { case Success>(): - // ✅ Защита: если data null или пустой — не вызываем finish if (result.data == null || result.data!.isEmpty) { emit(state.copyWith( status: SendPhotoStatus.failure, @@ -49,20 +48,19 @@ class SendPhotoBloc extends Bloc { return; } - // ✅ Передаём два параметра в UseCase final finishResult = await _finishRideUsecase( event.orderId, - result.data! // ✅ ! т.к. проверили выше + result.data! ); switch (finishResult) { - case Success(): + case Success(): emit(state.copyWith( status: SendPhotoStatus.success, recievedPhotoIds: result.data, )); case Failure(): - print("❌ FINISH ERROR: ${finishResult.failure.message}"); + print("FINISH ERROR: ${finishResult.failure.message}"); emit(state.copyWith( status: SendPhotoStatus.failure, errorMessage: 'Ошибка завершения поездки', diff --git a/lib/presentation/viewmodel/splash_bloc.dart b/lib/presentation/viewmodel/splash_bloc.dart index c131671..932c4de 100644 --- a/lib/presentation/viewmodel/splash_bloc.dart +++ b/lib/presentation/viewmodel/splash_bloc.dart @@ -16,6 +16,9 @@ class SplashBloc extends Bloc { on((event, emit) { emit(AuthPinVerified()); }); + on((event, emit) { + emit(AuthUnauthenticated()); + }); } Future _onAuthCheckRequested( diff --git a/lib/presentation/viewmodel/top_up_bloc.dart b/lib/presentation/viewmodel/top_up_bloc.dart index c46fbf2..19d6bb0 100644 --- a/lib/presentation/viewmodel/top_up_bloc.dart +++ b/lib/presentation/viewmodel/top_up_bloc.dart @@ -18,9 +18,10 @@ class TopUpBloc extends Bloc { required this.getCertificatesUsecase, required this.purchaseCertificateUsecase, required this.getUserCards, - }) : super(TopUpState(isLoading: true)) { + }) : super(const TopUpLoading()) { + on((event, emit) async { - emit(state.copyWith(isLoading: true)); + emit(const TopUpLoading()); final results = await Future.wait([ getCertificatesUsecase(), @@ -33,64 +34,68 @@ class TopUpBloc extends Bloc { switch ((certResult, cardResult)) { case (Success(data: final certs), Success(data: final cards)): emit( - state.copyWith( - certificates: certs, - cards: cards, - selectedTariff: certs!.length > 1 + TopUpLoaded( + certificates: certs ?? [], + cards: cards ?? [], + selectedTariff: certs != null && certs.length > 1 ? certs[1] - : (certs.isNotEmpty ? certs.first : null), - - selectedCard: cards!.isEmpty + : (certs != null && certs.isNotEmpty ? certs.first : null), + selectedCard: cards == null || cards.isEmpty ? null : cards.firstWhere( - (c) => c.isMain, - orElse: () => cards.first, - ), - isLoading: false, + (c) => c.isMain, + orElse: () => cards.first, + ), ), ); - - case ( - Success>(data: null), - Failure>(), - ): - // TODO: Handle this case. - throw UnimplementedError(); - case ( - Success>(data: [...]), - Failure>(), - ): - // TODO: Handle this case. - throw UnimplementedError(); - case (Failure>(), _): - // TODO: Handle this case. - throw UnimplementedError(); + default: + emit(const TopUpPaymentFailure('Не удалось загрузить данные')); } }); - on( - (event, emit) => emit(state.copyWith(selectedTariff: event.certificate)), - ); - on( - (event, emit) => emit(state.copyWith(selectedCard: event.card)), - ); - on( - (event, emit) => emit(state.copyWith(isAgreed: event.value)), - ); + + on((event, emit) { + if (state is TopUpLoaded) { + final currentState = state as TopUpLoaded; + emit(currentState.copyWith(selectedTariff: event.certificate)); + } + }); + + on((event, emit) { + if (state is TopUpLoaded) { + final currentState = state as TopUpLoaded; + emit(currentState.copyWith(selectedCard: event.card)); + } + }); + + on((event, emit) { + if (state is TopUpLoaded) { + final currentState = state as TopUpLoaded; + emit(currentState.copyWith(isAgreed: event.value)); + } + }); + on((event, emit) async { - emit(state.copyWith(isLoading: true)); + if (state is TopUpLoaded) { + final currentState = state as TopUpLoaded; - if (state.isAgreed) { - final result = await purchaseCertificateUsecase( - certificateId: state.selectedTariff!.id, - cardId:state.selectedCard!.id, - ); + if (currentState.isAgreed && + currentState.selectedTariff != null && + currentState.selectedCard != null) { - if (result is Success) { - emit(state.copyWith(isLoading: false)); + emit(currentState.copyWith(isProcessingPayment: true)); + + final result = await purchaseCertificateUsecase( + certificateId: currentState.selectedTariff!.id, + cardId: currentState.selectedCard!.id, + ); + + if (result is Success) { + emit(const TopUpPaymentSuccess()); + } else { + emit(const TopUpPaymentFailure('Ошибка при оплате')); + } } } - - }); } -} +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/verify_code_bloc.dart b/lib/presentation/viewmodel/verify_code_bloc.dart index a97433d..f5cdb00 100644 --- a/lib/presentation/viewmodel/verify_code_bloc.dart +++ b/lib/presentation/viewmodel/verify_code_bloc.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:be_happy/core/failures.dart'; import 'package:be_happy/core/result.dart'; +import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/usecase/verify_code_usecase.dart'; +import '../event/spalsh_event.dart'; import '../event/verify_code_event.dart'; import '../state/verify_code_state.dart'; @@ -21,11 +23,11 @@ import '../state/verify_code_state.dart'; class VerifyCodeBloc extends Bloc { final VerifyCodeUseCase _verifyCodeUseCase; - final LoginUseCase _loginUseCase; // 🔹 Добавляем поле для юзкейса логина + final LoginUseCase _loginUseCase; + final SplashBloc _splashBloc; Timer? _timer; - // 🔹 Обновляем конструктор - VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase) + VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase, this._splashBloc) : super(VerifyCodeState.initial()) { on((event, emit) { @@ -96,12 +98,14 @@ class VerifyCodeBloc extends Bloc { emit(state.copyWith(isSubmitting: true, error: null)); - // 🔹 Здесь автоматически подставится обновленный из стейта tempToken final result = await _verifyCodeUseCase.execute(state.code, state.tempToken); final newState = switch (result) { Success() => (() { _timer?.cancel(); + + _splashBloc.add(AuthCheckRequested()); + return state.copyWith( isSubmitting: false, isSuccess: true,