diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8140cd5..e6c0a2b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + @@ -49,6 +50,14 @@ + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 2716e00..e754925 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true android.enableJetifier=true yandexMapkit.variant=full +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/icons/card_result_screen.png b/assets/icons/card_result_screen.png new file mode 100644 index 0000000..92fa69a Binary files /dev/null and b/assets/icons/card_result_screen.png differ diff --git a/assets/icons/failed.png b/assets/icons/failed.png new file mode 100644 index 0000000..4e18e65 Binary files /dev/null and b/assets/icons/failed.png differ diff --git a/assets/icons/success.png b/assets/icons/success.png new file mode 100644 index 0000000..fb9f2d7 Binary files /dev/null and b/assets/icons/success.png differ 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/models/payment_card_response_dto.dart b/lib/data/models/payment_card_response_dto.dart index bb7b685..ccce5c8 100644 --- a/lib/data/models/payment_card_response_dto.dart +++ b/lib/data/models/payment_card_response_dto.dart @@ -2,7 +2,7 @@ import '../../domain/entities/payment_card.dart'; class PaymentCardResponseDto { final int id; - final int clientId; + final String bindingId; final int expirationMonth; final int expirationYear; final String cardHolder; @@ -12,7 +12,7 @@ class PaymentCardResponseDto { PaymentCardResponseDto({ required this.id, - required this.clientId, + required this.bindingId, required this.expirationMonth, required this.expirationYear, required this.cardHolder, @@ -24,7 +24,7 @@ class PaymentCardResponseDto { factory PaymentCardResponseDto.fromJson(Map json) { return PaymentCardResponseDto( id: json['id'] as int, - clientId: json['clientId'] as int, + bindingId: json['bindingId'] as String, expirationMonth: json['expirationMonth'] as int, expirationYear: json['expirationYear'] as int, cardHolder: json['cardHolder'] as String, @@ -37,13 +37,9 @@ class PaymentCardResponseDto { PaymentCard toEntity(String? fullCardNumber) { return PaymentCard( id: id, - clientId: clientId, - expirationMonth: expirationMonth, - expirationYear: expirationYear, - cardHolder: cardHolder, + bindingId: bindingId, cardLastNumber: cardLastNumber, isMain: isMain, - type: type, ); } } diff --git a/lib/data/network/api_service.dart b/lib/data/network/api_service.dart index 970ed04..b2e4a07 100644 --- a/lib/data/network/api_service.dart +++ b/lib/data/network/api_service.dart @@ -52,7 +52,11 @@ class ApiService { try { final response = await _dio.post( "$baseUrl/auth/client/login", - data: {"phone": phone, "model": model, "systemId": systemId}, + data: { + "phone": phone, + "model": model, + "systemId": systemId + }, ); if (response.statusCode == 200 || response.statusCode == 201) { @@ -143,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( @@ -177,13 +182,9 @@ class ApiService { cardsJson.map((cardJson) async { return PaymentCard( id: cardJson['id'], - clientId: cardJson['clientId'], - expirationMonth: cardJson['expirationMonth'], - expirationYear: cardJson['expirationYear'], - cardHolder: cardJson['cardHolder'], + bindingId: cardJson['bindingId'], cardLastNumber: cardJson['cardLastNumber'], isMain: cardJson['isMain'], - type: cardJson['cardType'], ); }), ); @@ -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', @@ -517,13 +518,13 @@ class ApiService { } } - Future activateSubscription({required int optionId}) async { + Future activateSubscription({required int optionId, required int cardId}) async { const url = "$baseUrl/scootersubscription/client"; try { final response = await _dio.post( url, - data: {"optionId": optionId}, + data: {"optionId": optionId, "cardId": cardId}, options: await _getAuthOptions(), ); @@ -539,31 +540,25 @@ class ApiService { } } - Future addPaymentCard({ - required String cardNumber, - required String cardHolder, - required int expirationMonth, - required int expirationYear, - required String cvv, - }) async { + Future> addPaymentCard() async { const url = "$baseUrl/client/card"; - final cleanCardNumber = cardNumber.replaceAll(' ', ''); + /*final cleanCardNumber = cardNumber.replaceAll(' ', '');*/ try { final response = await _dio.post( url, - data: { + /*data: { "cardNumber": cleanCardNumber, "cardHolder": cardHolder, "expirationMonth": expirationMonth, "expirationYear": expirationYear, "cvv": cvv, - }, + },*/ options: await _getAuthOptions(), ); if (response.statusCode == 200 || response.statusCode == 201) { - return; + return response.data; } throw AuthException('Непредвиденный статус: ${response.statusCode}', 0); @@ -714,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; @@ -765,7 +756,7 @@ class ApiService { } } - Future finishRide({ + Future finishRide({ required int orderId, required List filesId, }) async { @@ -776,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; @@ -1019,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}"); } } }, @@ -1040,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, @@ -1060,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/payment_repository_impl.dart b/lib/data/repositories/payment_repository_impl.dart index 9b2ac5c..7fcd325 100644 --- a/lib/data/repositories/payment_repository_impl.dart +++ b/lib/data/repositories/payment_repository_impl.dart @@ -32,25 +32,25 @@ class PaymentRepositoryImpl implements PaymentRepository { } @override - Future> addPaymentCard({ - required String cardNumber, + Future>> addPaymentCard( + /*required String cardNumber, required String cardHolder, required String expiryMonth, required String expiryYear, - required String cvv, - }) async { + required String cvv,*/ + ) async { try { - await apiService.addPaymentCard( - cardNumber: cardNumber, + final result = await apiService.addPaymentCard( + /*cardNumber: cardNumber, cardHolder: cardHolder, expirationMonth: int.parse(expiryMonth), expirationYear: int.parse(expiryYear), - cvv: cvv, + cvv: cvv,*/ ); // await securityService.saveCardFullNumber(cardId, cardNumber); - return Success(null); + return Success(result); } on AuthException catch (e) { return Failure(AuthFailure(e.attemptsLeft)); } on AuthBlockException catch (_) { @@ -98,9 +98,9 @@ class PaymentRepositoryImpl implements PaymentRepository { } @override - Future> activateSubscription(int optionId) async { + Future> activateSubscription(int optionId, int cardId) async { try { - final success = await apiService.activateSubscription(optionId: optionId); + final success = await apiService.activateSubscription(optionId: optionId, cardId: cardId); return Success(success); } on AuthException catch (e) { return Failure(AuthFailure(e.attemptsLeft)); 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 1198bff..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.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()), + () => 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/payment_card.dart b/lib/domain/entities/payment_card.dart index 15033e6..fd5dca1 100644 --- a/lib/domain/entities/payment_card.dart +++ b/lib/domain/entities/payment_card.dart @@ -1,43 +1,27 @@ class PaymentCard { final int id; - final int clientId; - final int expirationMonth; - final int expirationYear; - final String cardHolder; + final String? bindingId; final String cardLastNumber; final bool isMain; - final String type; PaymentCard({ required this.id, - required this.clientId, - required this.expirationMonth, - required this.expirationYear, - required this.cardHolder, + required this.bindingId, required this.cardLastNumber, required this.isMain, - required this.type, }); PaymentCard copyWith({ int? id, - int? clientId, - int? expirationMonth, - int? expirationYear, - String? cardHolder, + String? bindingId, String? cardLastNumber, bool? isMain, - String? type, }) { return PaymentCard( id: id ?? this.id, - clientId: clientId ?? this.clientId, - expirationMonth: expirationMonth ?? this.expirationMonth, - expirationYear: expirationYear ?? this.expirationYear, - cardHolder: cardHolder ?? this.cardHolder, + bindingId: bindingId ?? this.bindingId, cardLastNumber: cardLastNumber ?? this.cardLastNumber, isMain: isMain ?? this.isMain, - type: type ?? this.type, ); } } \ 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/payment_repository.dart b/lib/domain/repositories/payment_repository.dart index 13903aa..d1db710 100644 --- a/lib/domain/repositories/payment_repository.dart +++ b/lib/domain/repositories/payment_repository.dart @@ -4,17 +4,17 @@ import '../entities/payment_card.dart'; abstract class PaymentRepository { Future>> getPaymentCards(); - Future> addPaymentCard({ - required String cardNumber, + Future>> addPaymentCard( + /*required String cardNumber, required String cardHolder, required String expiryMonth, required String expiryYear, - required String cvv, - }); + required String cvv,*/ + ); Future> setMainPaymentCard(int cardId); Future> removePaymentCard(int cardId); - Future> activateSubscription(int optionId); + Future> activateSubscription(int optionId, int cardId); } \ No newline at end of file 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/activate_subscription_usecase.dart b/lib/domain/usecase/activate_subscription_usecase.dart index 1d5d893..7683106 100644 --- a/lib/domain/usecase/activate_subscription_usecase.dart +++ b/lib/domain/usecase/activate_subscription_usecase.dart @@ -6,7 +6,7 @@ class ActivateSubscriptionUsecase { ActivateSubscriptionUsecase(this.repository); - Future> call(int optionId) { - return repository.activateSubscription(optionId); + Future> call(int optionId, int cardId) { + return repository.activateSubscription(optionId, cardId); } } diff --git a/lib/domain/usecase/add_payment_card_usecase.dart b/lib/domain/usecase/add_payment_card_usecase.dart index 69af042..5430c38 100644 --- a/lib/domain/usecase/add_payment_card_usecase.dart +++ b/lib/domain/usecase/add_payment_card_usecase.dart @@ -6,19 +6,19 @@ class AddPaymentCardUsecase { AddPaymentCardUsecase(this.repository); - Future> call({ - required String cardNumber, + Future>> call( + /*required String cardNumber, required String cardHolder, required String expiryMonth, required String expiryYear, - required String cvv, - }) { + required String cvv,*/ + ) { return repository.addPaymentCard( - cardNumber: cardNumber, + /*cardNumber: cardNumber, cardHolder: cardHolder, expiryMonth: expiryMonth, expiryYear: expiryYear, - cvv: cvv, + cvv: cvv,*/ ); } } \ No newline at end of file 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 0d94689..a1e3563 100644 --- a/lib/presentation/components/scooter/scooter_info_section.dart +++ b/lib/presentation/components/scooter/scooter_info_section.dart @@ -2,20 +2,83 @@ import 'package:flutter/material.dart'; import 'scooter_info_item.dart'; class ScooterInfoSection extends StatelessWidget { - const ScooterInfoSection({super.key}); + final double distance; + final String 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: const [ - 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: 'пр. Московский, 33'), + children: [ + // Динамическая строка с зарядом + 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: '120 м'), - 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 5c749ce..aabdea3 100644 --- a/lib/presentation/components/scooter_bottom_sheet.dart +++ b/lib/presentation/components/scooter_bottom_sheet.dart @@ -128,6 +128,7 @@ class _ScooterBottomSheetState extends State { child: _ScooterCard( scooter: scooter, isActive: isActive, + address: state.address ?? "Unknown address", ), ), ), @@ -151,8 +152,9 @@ class _ScooterBottomSheetState extends State { class _ScooterCard extends StatelessWidget { final Scooter scooter; final bool isActive; + final String address; - const _ScooterCard({required this.scooter, required this.isActive}); + const _ScooterCard({required this.scooter, required this.isActive, required this.address}); @override Widget build(BuildContext context) { @@ -186,7 +188,9 @@ class _ScooterCard extends StatelessWidget { ), const SizedBox(width: 6), Text( - "${(scooter.distance?.toInt()) ?? 0}m", + (scooter.distance?.toInt() ?? 0) >= 1000 + ? '${((scooter.distance?.toInt() ?? 0) / 1000).toStringAsFixed(1)} km' + : '${scooter.distance?.toInt() ?? 0} m', style: const TextStyle(color: Colors.white, fontSize: 14), ), ], @@ -229,7 +233,7 @@ class _ScooterCard extends StatelessWidget { GestureDetector( onTap: () { - context.go('/home/scooter/${scooter.id}'); + context.go('/home/scooter/${scooter.id}', extra: [address, scooter.distance]); }, child: Container( height: 42, @@ -241,7 +245,7 @@ class _ScooterCard extends StatelessWidget { width: double.infinity, fontSize: 12, onTap: () { - context.go('/home/scooter/${scooter.id}'); + 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 f30365d..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,13 +192,18 @@ class _PaymentMethodSheetState extends State { return Padding( padding: const EdgeInsets.only(bottom: 12), child: PaymentOption( - title: card.type, - subtitle: '****${card.cardLastNumber}', + title: card.isMain ? "Основная" : "Карта", + subtitle: '${card.cardLastNumber}', isSelected: _selectedPaymentMethod == index, - onTap: () { + onTap: () async { setState(() { _selectedPaymentMethod = index; }); + + context.read().add( + SelectPaymentCardRequested(card), + ); + Navigator.pop(context, card); }, ), diff --git a/lib/presentation/components/sheet/scooter_bottom_sheet.dart b/lib/presentation/components/sheet/scooter_bottom_sheet.dart index fd07d3a..38dd739 100644 --- a/lib/presentation/components/sheet/scooter_bottom_sheet.dart +++ b/lib/presentation/components/sheet/scooter_bottom_sheet.dart @@ -151,6 +151,7 @@ class _ScooterBottomSheetState extends State { child: _ScooterCard( scooter: scooter, isActive: isActive, + address: state.address ?? "Unknown address", ), ), ), @@ -174,9 +175,10 @@ class _ScooterBottomSheetState extends State { class _ScooterCard extends StatelessWidget { final Scooter scooter; + final String address; final bool isActive; - const _ScooterCard({required this.scooter, required this.isActive}); + const _ScooterCard({required this.scooter, required this.isActive, required this.address}); @override Widget build(BuildContext context) { @@ -210,7 +212,9 @@ class _ScooterCard extends StatelessWidget { ), const SizedBox(width: 6), Text( - "${(scooter.distance?.toInt()) ?? 0}m", + (scooter.distance?.toInt() ?? 0) >= 1000 + ? '${((scooter.distance?.toInt() ?? 0) / 1000).toStringAsFixed(1)} km' + : '${scooter.distance?.toInt() ?? 0} m', style: const TextStyle(color: Colors.white, fontSize: 14), ), ], @@ -262,7 +266,7 @@ class _ScooterCard extends StatelessWidget { width: double.infinity, fontSize: 12, onTap: () { - Navigator.pop(context, scooter); + Navigator.pop(context, [scooter, address]); }, ), ], diff --git a/lib/presentation/components/sheet/tariff_sheet.dart b/lib/presentation/components/sheet/tariff_sheet.dart index f853b23..35cf45e 100644 --- a/lib/presentation/components/sheet/tariff_sheet.dart +++ b/lib/presentation/components/sheet/tariff_sheet.dart @@ -12,6 +12,8 @@ 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'; import '../../state/tariff_sheet_state.dart'; @@ -39,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) { @@ -134,7 +154,6 @@ class _TariffSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 🔹 HEADER Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( @@ -226,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, @@ -288,10 +307,10 @@ class _TariffSheetState extends State { child: PaymentOption( title: state.useBalance ? 'Баланс' - : state.selectedCard!.type, + : "Карта", subtitle: state.useBalance ? '${state.userBalance.toStringAsFixed(2)} BYN' - : '****${state.selectedCard!.cardLastNumber}', + : '${state.selectedCard!.cardLastNumber}', isSelected: true, onTap: () async { final result = await showModalBottomSheet( @@ -301,6 +320,8 @@ class _TariffSheetState extends State { builder: (innerContext) => BlocProvider( create: (context) => PaymentMethodSheetBloc( getIt(), + getIt(), + getIt(), )..add(PaymentMethodSheetStarted()), child: PaymentMethodSheet( initialSelectedCard: state.useBalance ? null : state.selectedCard, @@ -381,7 +402,6 @@ class _TariffSheetState extends State { const SizedBox(height: 12), - // 🔹 КНОПКА "ЗАБРОНИРОВАТЬ" Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: GradientButton( @@ -494,13 +514,10 @@ class _TariffCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - // Резервируем место под иконку инфо справа, - // чтобы текст не залез под неё const SizedBox(width: 24), ], ), const SizedBox(height: 12), - // Цена + текст рядом Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, @@ -540,7 +557,6 @@ class _TariffCard extends StatelessWidget { ], ), - // Кнопка-иконка в верхнем правом углу Positioned( top: 0, right: 0, @@ -550,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 3e5c668..98e8bfe 100644 --- a/lib/presentation/components/side_menu.dart +++ b/lib/presentation/components/side_menu.dart @@ -63,68 +63,90 @@ 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( icon: 'assets/icons/logout_icon.png', title: 'Выход', - onTap: () => { - context.go('/login'), - context.read().stopNotificationStream(), - context.read().add(LogoutPressed()), + onTap: () { + context.read().stopNotificationStream(); + context.read().add(LogoutPressed()); + context.go('/login'); }, ), ], 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/payment_method_sheet_event.dart b/lib/presentation/event/payment_method_sheet_event.dart index b3fdd3c..9faaa26 100644 --- a/lib/presentation/event/payment_method_sheet_event.dart +++ b/lib/presentation/event/payment_method_sheet_event.dart @@ -1,3 +1,10 @@ +import '../../domain/entities/payment_card.dart'; + sealed class PaymentMethodSheetEvent {} class PaymentMethodSheetStarted extends PaymentMethodSheetEvent {} + +class SelectPaymentCardRequested extends PaymentMethodSheetEvent { + final PaymentCard card; + SelectPaymentCardRequested(this.card); +} diff --git a/lib/presentation/event/payment_methods_event.dart b/lib/presentation/event/payment_methods_event.dart index 53ed38d..85f6e43 100644 --- a/lib/presentation/event/payment_methods_event.dart +++ b/lib/presentation/event/payment_methods_event.dart @@ -2,6 +2,8 @@ sealed class PaymentMethodsEvent {} class PaymentMethodsStarted extends PaymentMethodsEvent {} +class AddPayemntCardPressed extends PaymentMethodsEvent {} + class PaymentMethodsDeleteCard extends PaymentMethodsEvent { final int cardId; 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/event/subscription_details_event.dart b/lib/presentation/event/subscription_details_event.dart index 485deb6..62ff771 100644 --- a/lib/presentation/event/subscription_details_event.dart +++ b/lib/presentation/event/subscription_details_event.dart @@ -1,3 +1,4 @@ +import '../../domain/entities/payment_card.dart'; import '../../domain/entities/subscription_period.dart'; abstract class SubscriptionDetailsEvent {} @@ -17,4 +18,10 @@ class ToggleAgreementEvent extends SubscriptionDetailsEvent { ToggleAgreementEvent(this.value); } -class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {} \ No newline at end of file +class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {} + +class SelectCardEvent extends SubscriptionDetailsEvent { + final PaymentCard card; + + SelectCardEvent(this.card); +} \ No newline at end of file diff --git a/lib/presentation/event/top_up_event.dart b/lib/presentation/event/top_up_event.dart index 38ac3c2..7f21d77 100644 --- a/lib/presentation/event/top_up_event.dart +++ b/lib/presentation/event/top_up_event.dart @@ -20,4 +20,6 @@ class SelectCard extends TopUpEvent { class ToggleAgreement extends TopUpEvent { final bool value; ToggleAgreement(this.value); -} \ No newline at end of file +} + +class TopUpButtonPressed extends TopUpEvent {} \ No newline at end of file diff --git a/lib/presentation/event/verify_code_event.dart b/lib/presentation/event/verify_code_event.dart index b7a54a7..8601233 100644 --- a/lib/presentation/event/verify_code_event.dart +++ b/lib/presentation/event/verify_code_event.dart @@ -13,6 +13,11 @@ class CodeChanged extends VerifyCodeEvent { CodeChanged(this.code); } +class TimerTicked extends VerifyCodeEvent { + final int secondsLeft; + TimerTicked(this.secondsLeft); +} + class ResendCodePressed extends VerifyCodeEvent {} class VerifyCodeSubmitted extends VerifyCodeEvent {} \ No newline at end of file diff --git a/lib/presentation/navigation/app_router.dart b/lib/presentation/navigation/app_router.dart index 7b994bc..d446bb1 100644 --- a/lib/presentation/navigation/app_router.dart +++ b/lib/presentation/navigation/app_router.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:be_happy/presentation/screens/card_binding_result_screen.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:be_happy/core/app_colors.dart'; import 'package:be_happy/di/service_locator.dart'; @@ -43,6 +44,7 @@ import 'package:path/path.dart'; import '../../domain/entities/scooter.dart'; import '../../domain/usecase/activate_subscription_usecase.dart'; +import '../../domain/usecase/add_payment_card_usecase.dart'; import '../../domain/usecase/book_scooter_usecase.dart'; import '../../domain/usecase/create_pin_usecase.dart'; import '../../domain/usecase/get_address_by_point_usecase.dart'; @@ -139,7 +141,8 @@ class AppRouter { createPinUseCase: getIt(), verifyPinUsecase: getIt(), isPinSetUsecase: getIt(), - )..add(PinScreenStarted()), + ) + ..add(PinScreenStarted()), child: const PinLoginScreen(), ) ), @@ -151,6 +154,26 @@ class AppRouter { path: '/license-agreement', builder: (context, state) => const LicenseAgreementScreen(), ), + GoRoute( + path: '/card-result', + builder: (context, state) { + final modeParam = state.uri.queryParameters['status']; + + CardBindingStatus status = CardBindingStatus.success; + String title = 'Карта успешно\nпривязана!'; + + if (modeParam == 'failed') { + status = CardBindingStatus.failed; + title = 'Извините, что-то\nпошло не так'; + } + + return CardBindingResultScreen( + status: status, + title: title, + cardImagePath: 'assets/icons/card_result_screen.png', + ); + }, + ), GoRoute( path: '/home', builder: (context, state) => const MapScreen(), @@ -237,7 +260,11 @@ class AppRouter { type: MaterialType.transparency, child: BlocProvider( create: (context) => - PaymentMethodSheetBloc(getIt()), + PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + ), child: PaymentMethodSheet(), ), ), @@ -297,12 +324,20 @@ class AppRouter { ], ), GoRoute( - path: 'scooter/:id', - builder: (context, state) => - BlocProvider( + path: 'scooter/:id', + builder: (context, state) { + 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(), - ), + child: ScooterDetailScreen(address: address, distance: distance), + ); + } ), GoRoute( path: 'order-photos/:orderId', @@ -371,6 +406,7 @@ class AppRouter { create: (context) => SubscriptionDetailsBloc( getIt(), + getIt(), getIt(), getIt(), ) @@ -394,39 +430,44 @@ class AppRouter { builder: (context, state) => const NewsScreen(), ), GoRoute( - path: 'qr-info', - builder: (context, state) => const QRScanInfoScreen(), - routes: [ - GoRoute( - path: 'qr-scan', - builder: (context, state) => const QrScanScreen(), - ), - GoRoute( - path: 'qr-input', - builder: (context, state) => BlocProvider( - create: (context) => - ScooterCodeBloc( - getScooterByTitleUsecase: getIt(), - ), - child: ScooterCodeInputScreen(), + path: 'qr-info', + builder: (context, state) => const QRScanInfoScreen(), + routes: [ + GoRoute( + path: 'qr-scan', + builder: (context, state) => const QrScanScreen(), ), - ), - ] + GoRoute( + path: 'qr-input', + builder: (context, state) => + BlocProvider( + create: (context) => + ScooterCodeBloc( + getScooterByTitleUsecase: getIt< + GetScooterByTitleUsecase>(), + ), + child: ScooterCodeInputScreen(), + ), + ), + ] ), GoRoute( path: 'payment-methods', - builder: (context, state) => - BlocProvider( - create: (context) => - PaymentMethodsBloc( - 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', @@ -450,13 +491,35 @@ 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', + ); + }, + ), + ], ), ], ), GoRoute( - path: 'order-history', - builder: (context, state) => const OrderHistoryScreen(), - routes: [] + path: 'order-history', + builder: (context, state) => const OrderHistoryScreen(), + routes: [] ), GoRoute( path: 'notifications', @@ -466,6 +529,24 @@ class AppRouter { ), ], + onException: (context, state, router) { + final location = state.uri.toString(); + + if (location.startsWith('com.behappy.app://')) { + + String normalizedPath = location + .replaceFirst('com.behappy.app://', '/') + .replaceAll('/?', '?') + .replaceAll(' ', ''); + + print("Нормализованный путь диплинка: $normalizedPath"); + + router.go(normalizedPath); + } else { + print('GoRouter Exception: ${state.error}'); + } + }, + observers: [BotToastNavigatorObserver()], redirect: (BuildContext context, GoRouterState state) { @@ -476,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 new file mode 100644 index 0000000..64c2804 --- /dev/null +++ b/lib/presentation/screens/card_binding_result_screen.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../components/gradient_button.dart'; + +enum CardBindingStatus { success, failed } + +class CardBindingResultScreen extends StatefulWidget { + final CardBindingStatus status; + final String title; + final String cardImagePath; + + const CardBindingResultScreen({ + super.key, + required this.status, + required this.title, + required this.cardImagePath, + }); + + @override + State createState() => _CardBindingResultScreenState(); +} + +class _CardBindingResultScreenState extends State { + Timer? _autoPopTimer; + + @override + void initState() { + super.initState(); + if (widget.status == CardBindingStatus.success) { + _autoPopTimer = Timer(const Duration(seconds: 5), () { + if (mounted) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); + } + }); + } + } + + @override + void dispose() { + _autoPopTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isSuccess = widget.status == CardBindingStatus.success; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF1B284F), + Color(0xFF0F172A), + ], + ), + ), + child: SafeArea( + child: Stack( + children: [ + // Positioned( + // bottom: 50, + // left: 0, + // right: 0, + // child: Image.asset( + // 'assets/wave.png', + // fit: BoxFit.cover, + // ), + // ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // const Spacer(), + + Text( + widget.title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 40), + + Image.asset( + isSuccess + ? 'assets/icons/success.png' + : 'assets/icons/failed.png', + width: 140, + height: 140, + ), + + const SizedBox(height: 40), + + if (isSuccess) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 40.0), + // child: Image.asset( + // widget.cardImagePath, + // width: double.infinity, + // fit: BoxFit.contain, + // ), + ), + ] else ...[ + const Text( + 'Проверьте подключение к\nинтернету и попробуйте снова', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF94A3B8), + fontSize: 16, + height: 1.4, + ), + ), + + const SizedBox(height: 40), + + GradientButton( + text: "Попробовать снова", + onTap: () { + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); + }, + enabled: true, + showArrows: true, + ), + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + height: 56, + child: TextButton( + onPressed: () + { + final timestamp = DateTime.now().millisecondsSinceEpoch; + context.go("/home/payment-methods?refreshId=$timestamp"); + }, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + backgroundColor: const Color(0xFF0F172A).withOpacity(0.4), + ), + child: const Text( + 'Закрыть', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} 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 b8aafa1..84c3d3f 100644 --- a/lib/presentation/screens/payment_confirm_screen.dart +++ b/lib/presentation/screens/payment_confirm_screen.dart @@ -5,6 +5,8 @@ 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'; import '../components/payment_option.dart'; @@ -133,11 +135,6 @@ class _PaymentConfirmScreenContent extends StatelessWidget { const SizedBox(height: 24), - _buildInfoLabel('Начислено баллов:'), - _buildValueRow('points_icon.png', '2 балла'), - - const SizedBox(height: 24), - Row( children: [ Expanded( @@ -148,7 +145,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { _buildInfoLabel('Стоимость поездки:'), _buildValueRow( 'money_icon.png', - '17,17 BYN', + order?.totalOrderPricePrint ?? '- BYN', ), ], ), @@ -161,7 +158,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { _buildInfoLabel('Оплачено:'), _buildValueRow( 'money_icon.png', - '10,00 BYN', + order?.payTotalPricePrint ?? '0,00 BYN', ), ], ), @@ -192,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, @@ -212,10 +209,10 @@ class _PaymentConfirmScreenContent extends StatelessWidget { (state.useBalance || state.selectedCard != null) ? Padding(padding: const EdgeInsets.symmetric(horizontal: 20), child: PaymentOption( - title: state.useBalance ? 'Баланс' : state.selectedCard!.type, + title: state.useBalance ? 'Баланс' : "", subtitle: state.useBalance ? '${state.userBalance.toStringAsFixed(2)} BYN' - : '****${state.selectedCard!.cardLastNumber}', + : '${state.selectedCard!.cardLastNumber}', isSelected: true, onTap: () async { final result = await showModalBottomSheet( @@ -225,6 +222,8 @@ class _PaymentConfirmScreenContent extends StatelessWidget { builder: (innerContext) => BlocProvider( 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 f206781..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( @@ -184,11 +184,14 @@ class PaymentMethodsScreen extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: () async { - final isCardAdded = await context.push('/home/payment-methods/add-card'); + context.read().add(AddPayemntCardPressed()); + /*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( @@ -264,12 +267,7 @@ class _CardItem extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Image.asset( - _getDefaultIconPath(card.type), - width: 40, - height: 40, - fit: BoxFit.contain, - ), + Icon(Icons.credit_card, color: Color(0xFFFFFFFF), size: 40), const SizedBox(width: 12), Expanded( child: GestureDetector( @@ -279,7 +277,7 @@ class _CardItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${card.type} ****${card.cardLastNumber}', + 'Карта ${card.cardLastNumber}', style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), diff --git a/lib/presentation/screens/phone_login_screen.dart b/lib/presentation/screens/phone_login_screen.dart index 961d8db..e6d617f 100644 --- a/lib/presentation/screens/phone_login_screen.dart +++ b/lib/presentation/screens/phone_login_screen.dart @@ -77,19 +77,20 @@ class _PhoneLoginScreenState extends State { return BlocConsumer( listener: (context, state) { if (state.isSuccess) { - context.go("/pin"); + } else if (state.error != null) { - setState(() { - _isFirstTry = false; - }); + setState(() { _isFirstTry = false; }); codeController.clear(); FocusScope.of(context).requestFocus(codeFocusNode); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(state.error!))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!))); } else if (state.isBlocked) { context.go("/block"); } + + if (state.secondsLeft == 0 && codeFocusNode.hasFocus) { + codeFocusNode.unfocus(); + codeController.clear(); + } }, builder: (context, state) { return Scaffold( @@ -126,12 +127,21 @@ class _PhoneLoginScreenState extends State { ), const SizedBox(height: 35), GestureDetector( - onTap: openKeyboard, + onTap: state.secondsLeft > 0 ? openKeyboard : null, child: CodeDots(code: state.code, length: 6), ), const SizedBox(height: 8), - if (!_isFirstTry) + if (state.secondsLeft == 0) + const Text( + "Время действия кода истекло. Запросите новый.", + style: TextStyle( + color: AppColors.pinError, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ) + else if (!_isFirstTry) Text( "Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}", style: const TextStyle( @@ -145,14 +155,14 @@ class _PhoneLoginScreenState extends State { text: state.secondsLeft == 0 ? "Отправить код повторно" : "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.", - onTap: state.secondsLeft == 0 + onTap: (state.secondsLeft == 0 && !state.isSubmitting) ? () { - context.read().add( - ResendCodePressed(), - ); - } - : () {}, - enabled: state.secondsLeft == 0, + context.read().add( + ResendCodePressed(), + ); + } + : null, + enabled: state.secondsLeft == 0 && !state.isSubmitting, width: 250, ), const Spacer(), @@ -167,6 +177,7 @@ class _PhoneLoginScreenState extends State { keyboardType: TextInputType.number, maxLength: 6, autofocus: true, + enabled: state.secondsLeft > 0, ), ), ), diff --git a/lib/presentation/screens/scooter_detail_screen.dart b/lib/presentation/screens/scooter_detail_screen.dart index 096e93d..ec8df7e 100644 --- a/lib/presentation/screens/scooter_detail_screen.dart +++ b/lib/presentation/screens/scooter_detail_screen.dart @@ -12,7 +12,9 @@ import '../state/scooter_detail_state.dart'; import '../viewmodel/scooter_detail_bloc.dart'; class ScooterDetailScreen extends StatelessWidget { - const ScooterDetailScreen({super.key}); + final String address; + final double distance; + const ScooterDetailScreen({super.key, required this.address, required this.distance}); @override Widget build(BuildContext context) { @@ -83,7 +85,7 @@ class ScooterDetailScreen extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Expanded(child: ScooterInfoSection()), + 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 4481c3b..e881388 100644 --- a/lib/presentation/screens/subscription_details_screen.dart +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -3,13 +3,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.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/gradient_button.dart'; +import '../components/payment_option.dart'; import '../components/period_selector.dart'; import '../components/content_parser.dart'; // ✅ Парсер контента +import '../components/sheet/payment_method_sheet.dart'; +import '../event/payment_method_sheet_event.dart'; import '../event/subscription_details_event.dart'; import '../state/susbcription_details_state.dart'; +import '../viewmodel/payment_method_sheet_bloc.dart'; import '../viewmodel/susbcription_details_bloc.dart'; class SubscriptionDetailsScreen extends StatelessWidget { @@ -50,11 +59,9 @@ class SubscriptionDetailsScreen extends StatelessWidget { ), const SizedBox(height: 20), - // 🔹 Контент Expanded( child: Stack( children: [ - // Волна снизу Positioned( bottom: 0, left: 0, @@ -108,10 +115,9 @@ class SubscriptionDetailsScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 🔹 Обёртка для растягивания контента на всю ширину Column( - crossAxisAlignment: CrossAxisAlignment.stretch, // ✅ Растягивает детей на всю ширину - mainAxisSize: MainAxisSize.min, // ✅ Не занимает лишнее место по вертикали + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: ContentParser.parseContent( textJson: state.subscription.fullDescriptionJson, text: state.subscription.fullDescription, @@ -121,6 +127,14 @@ class SubscriptionDetailsScreen extends StatelessWidget { if (isAvailableForPurchase) ...[ const SizedBox(height: 30), _ActionCard(state: state), + const SizedBox(height: 24), + const Text( + 'Способ оплаты', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(height: 12), + _buildCardSelector(state, context), + const SizedBox(height: 30), GradientButton( text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать', @@ -129,7 +143,7 @@ class SubscriptionDetailsScreen extends StatelessWidget { ActivateSubscriptionPressed(), ); }, - enabled: state.isAgreed, + enabled: state.isAgreed && state.selectedCard != null, width: double.infinity, height: 56, fontSize: 16, @@ -141,8 +155,78 @@ class SubscriptionDetailsScreen extends StatelessWidget { ), ); } + + Widget _buildCardSelector(DetailsContentState state, BuildContext context) { + if (state.selectedCard != null) { + return PaymentOption( + title: "Карта", + subtitle: state.selectedCard.cardLastNumber, + isSelected: true, + onTap: () async { + final selectedCard = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(showBalance: false), + ), + ); + + if (selectedCard != null && context.mounted) { + context.read().add(SelectCardEvent(selectedCard)); + } + }, + ); + } + + return Container( + height: 56, + 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: () { + context.pushReplacement('/home/payment-method-sheet'); + }, + borderRadius: BorderRadius.circular(24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Добавить способ оплаты', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + 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)), + ], + ), + ), + ), + ), + ); + } } + + class _ActionCard extends StatelessWidget { final DetailsContentState state; @@ -216,6 +300,7 @@ class _ActionCard extends StatelessWidget { ), ); } + } class _PriceRow extends StatelessWidget { diff --git a/lib/presentation/screens/top_up_screen.dart b/lib/presentation/screens/top_up_screen.dart index 6e772c6..916e98b 100644 --- a/lib/presentation/screens/top_up_screen.dart +++ b/lib/presentation/screens/top_up_screen.dart @@ -1,3 +1,4 @@ +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'; @@ -6,18 +7,23 @@ 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( @@ -26,55 +32,77 @@ class TopUpScreen extends StatelessWidget { child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 16), - const CustomAppBar(title: 'Пополнение баланса'), - const SizedBox(height: 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 is TopUpLoading) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } - BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + if (state is TopUpLoaded) { + if (state.isProcessingPayment) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } - return Expanded( - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Expanded( + child: Stack( children: [ - _buildTariffList(state, context), - const SizedBox(height: 30), - _buildPriceInfo(state), - const SizedBox(height: 40), - const Text( - 'Способ оплаты', - style: TextStyle(color: Colors.white70), + 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), + ], ), - const SizedBox(height: 15), - _buildCardSelector(state, context), - const SizedBox(height: 20), - _buildAgreement(state, context), - const Spacer(), - _buildPayButton(state), - 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( @@ -85,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), ), @@ -123,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( @@ -153,13 +178,13 @@ 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), child: PaymentOption( - title: state.selectedCard!.type, - subtitle: '****${state.selectedCard!.cardLastNumber}', + title: "Карта", + subtitle: '${state.selectedCard!.cardLastNumber}', isSelected: true, onTap: () async { final selectedCard = await showModalBottomSheet( @@ -167,10 +192,12 @@ class TopUpScreen extends StatelessWidget { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (innerContext) => BlocProvider( - create: (context) => - PaymentMethodSheetBloc(getIt()) - ..add(PaymentMethodSheetStarted()), - child: const PaymentMethodSheet(showBalance: false,), + create: (context) => PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(showBalance: false), ), ); @@ -202,7 +229,6 @@ class TopUpScreen extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( 'Способ оплаты', style: TextStyle( @@ -212,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)), ], ), ), @@ -236,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, ), @@ -260,10 +273,10 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildPayButton(TopUpState state) { + Widget _buildPayButton(TopUpLoaded state, VoidCallback onTap) { return GradientButton( text: 'Оплатить', - onTap: state.isAgreed ? () {} : null, + onTap: state.isAgreed ? onTap : null, enabled: state.isAgreed, showArrows: true, height: 56, @@ -271,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/susbcription_details_state.dart b/lib/presentation/state/susbcription_details_state.dart index 7ce8636..ecc4740 100644 --- a/lib/presentation/state/susbcription_details_state.dart +++ b/lib/presentation/state/susbcription_details_state.dart @@ -1,3 +1,4 @@ +import 'package:be_happy/domain/entities/payment_card.dart'; import 'package:be_happy/domain/entities/subscription.dart'; import '../../domain/entities/subscription_period.dart'; @@ -8,19 +9,24 @@ class DetailsLoading extends SubscriptionDetailsState {} class DetailsError extends SubscriptionDetailsState { final String message; + DetailsError(this.message); } class DetailsContentState extends SubscriptionDetailsState { final Subscription subscription; final SubscriptionPeriod selectedPeriod; + final List cards; + final PaymentCard selectedCard; final bool isAgreed; - final bool isAlreadyPurchased; // ✅ Куплена ли эта подписка сейчас - final bool isSuccess; // ✅ Сигнал для навигатора назад + final bool isAlreadyPurchased; + final bool isSuccess; DetailsContentState({ required this.subscription, required this.selectedPeriod, + required this.selectedCard, + this.cards = const [], this.isAgreed = false, this.isAlreadyPurchased = false, this.isSuccess = false, @@ -28,6 +34,7 @@ class DetailsContentState extends SubscriptionDetailsState { DetailsContentState copyWith({ SubscriptionPeriod? selectedPeriod, + PaymentCard? selectedCard, bool? isAgreed, bool? isAlreadyPurchased, bool? isSuccess, @@ -35,9 +42,10 @@ class DetailsContentState extends SubscriptionDetailsState { return DetailsContentState( subscription: this.subscription, selectedPeriod: selectedPeriod ?? this.selectedPeriod, + selectedCard: selectedCard ?? this.selectedCard, isAgreed: isAgreed ?? this.isAgreed, isAlreadyPurchased: isAlreadyPurchased ?? this.isAlreadyPurchased, isSuccess: isSuccess ?? this.isSuccess, ); } -} \ No newline at end of file +} 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/add_card_bloc.dart b/lib/presentation/viewmodel/add_card_bloc.dart index 91a2ba9..476bf42 100644 --- a/lib/presentation/viewmodel/add_card_bloc.dart +++ b/lib/presentation/viewmodel/add_card_bloc.dart @@ -52,17 +52,17 @@ class AddCardBloc extends Bloc { } final result = await addPaymentCardUsecase( - cardNumber: state.cardNumber.replaceAll(' ', ''), + /*cardNumber: state.cardNumber.replaceAll(' ', ''), expiryMonth: expiryParts[0], expiryYear: expiryParts[1], cardHolder: state.cardHolder.trim(), - cvv: state.cvv, + cvv: state.cvv,*/ ); if (result is Success) { emit(state.copyWith(status: AddCardStatus.success)); } else if (result is Failure) { - final failure = result.failure; + /*final failure = result.failure; String errorMessage = 'Ошибка добавления карты'; @@ -72,12 +72,12 @@ class AddCardBloc extends Bloc { errorMessage = 'Доступ заблокирован'; } else if (failure is UnknownFailure) { errorMessage = failure.message ?? 'Неизвестная ошибка'; - } + }*/ - emit(state.copyWith( + /* emit(state.copyWith( status: AddCardStatus.failure, errorMessage: errorMessage, - )); + ));*/ } } 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 72268d0..e039989 100644 --- a/lib/presentation/viewmodel/map_bloc.dart +++ b/lib/presentation/viewmodel/map_bloc.dart @@ -264,9 +264,10 @@ class MapBloc extends Bloc { FutureOr _onLogoutPressed( LogoutPressed event, Emitter emit, - ) { - logoutUseCase(); - splashBloc.add(AuthCheckRequested()); + ) async { + await logoutUseCase(); + stopNotificationStream(); + 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 77069f3..c998cab 100644 --- a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart +++ b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart @@ -3,13 +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) + PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase, this._getProfileUseCase) : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { on(_onStarted); } @@ -22,7 +26,59 @@ class PaymentMethodSheetBloc extends Bloc>) { + emit(state.copyWith( + status: PaymentMethodSheetStatus.success, + balance: profile.balance, + cards: result.data ?? [], + )); + } else { + emit(state.copyWith( + status: PaymentMethodSheetStatus.failure, + errorMessage: 'Failed to load payment cards', + )); + } + } catch (e) { + emit(state.copyWith( + status: PaymentMethodSheetStatus.failure, + errorMessage: e.toString(), + )); + } + } + Future _onSelectCard( + SelectPaymentCardRequested event, + Emitter emit, + ) async { + // Включаем локальный лоадер на шторке, либо просто ждем ответа + emit(state.copyWith(status: PaymentMethodSheetStatus.loading)); + + final result = await _setMainPaymentCardUsecase(event.card.id); + + if (result is Success) { + final cardsResult = await _getPaymentCardsUsecase(); + if (cardsResult is Success>) { + emit(state.copyWith( + status: PaymentMethodSheetStatus.success, + cards: cardsResult.data ?? [], + )); + } else { + emit(state.copyWith(status: PaymentMethodSheetStatus.failure, errorMessage: 'Ошибка обновления списка карт')); + } + } else { + emit(state.copyWith( + status: PaymentMethodSheetStatus.failure, + errorMessage: 'Не удалось установить карту основной', + )); + } + } + + // Выносим загрузку в отдельный метод для переиспользования + Future _loadCards(Emitter emit) async { + try { + final result = await _getPaymentCardsUsecase(); if (result is Success>) { emit(state.copyWith( status: PaymentMethodSheetStatus.success, diff --git a/lib/presentation/viewmodel/payment_methods_bloc.dart b/lib/presentation/viewmodel/payment_methods_bloc.dart index 7ac3819..53ba9cc 100644 --- a/lib/presentation/viewmodel/payment_methods_bloc.dart +++ b/lib/presentation/viewmodel/payment_methods_bloc.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + +import 'package:be_happy/domain/usecase/add_payment_card_usecase.dart'; import 'package:be_happy/domain/usecase/get_profile_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../core/result.dart'; import '../../core/failures.dart'; @@ -15,16 +19,19 @@ class PaymentMethodsBloc extends Bloc final RemovePaymentCardUsecase _removePaymentCardUsecase; final SetMainPaymentCardUsecase _setMainPaymentCardUsecase; final GetProfileUseCase _getProfileUseCase; + final AddPaymentCardUsecase _addPaymentCardUsecase; PaymentMethodsBloc( this._getPaymentCardsUsecase, this._removePaymentCardUsecase, this._setMainPaymentCardUsecase, this._getProfileUseCase, + this._addPaymentCardUsecase, ) : super(PaymentMethodsState(status: PaymentMethodsStatus.initial)) { on(_onStarted); on(_onDeleteCard); on(_onSetMainCard); + on(_onAddPayemntCardPressed); } Future _onStarted( @@ -39,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 ?? [], @@ -136,4 +145,54 @@ class PaymentMethodsBloc extends Bloc )); } } -} + + FutureOr _onAddPayemntCardPressed( + AddPayemntCardPressed event, + Emitter emit + ) async { + emit(state.copyWith(status: PaymentMethodsStatus.loading)); + + try { + final result = await _addPaymentCardUsecase(); + + if (result is Success>) { + final formUrlString = result.data?['formUrl'] as String?; + + if (formUrlString != null && formUrlString.isNotEmpty) { + final url = Uri.parse(formUrlString); + + if (await canLaunchUrl(url)) { + launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + emit(state.copyWith(status: PaymentMethodsStatus.success)); + } else { + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: 'Не удалось открыть ссылку для оплаты', + )); + } + } else { + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: 'Ссылка для привязки карты не получена от сервера', + )); + } + + } else if (result is Failure) { + String errorMessage = 'Ошибка при добавлении карты'; + + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: errorMessage, + )); + } + + } catch (e) { + emit(state.copyWith( + status: PaymentMethodsStatus.failure, + errorMessage: e.toString(), + )); + } + }} 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/susbcription_details_bloc.dart b/lib/presentation/viewmodel/susbcription_details_bloc.dart index 24d4208..e331b57 100644 --- a/lib/presentation/viewmodel/susbcription_details_bloc.dart +++ b/lib/presentation/viewmodel/susbcription_details_bloc.dart @@ -1,6 +1,8 @@ import 'package:be_happy/core/result.dart'; +import 'package:be_happy/domain/entities/payment_card.dart'; import 'package:be_happy/domain/entities/subscription.dart'; import 'package:be_happy/domain/usecase/activate_subscription_usecase.dart'; +import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,27 +14,35 @@ import '../state/susbcription_details_state.dart'; class SubscriptionDetailsBloc extends Bloc { final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase; + final GetPaymentCardsUsecase getPaymentCardsUsecase; final ActivateSubscriptionUsecase activateSubscriptionUsecase; final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; SubscriptionDetailsBloc( this.getSubscriptionByIdUsecase, + this.getPaymentCardsUsecase, this.activateSubscriptionUsecase, this.getClientSubscriptionsUsecase, ) : super(DetailsLoading()) { on((event, emit) async { emit(DetailsLoading()); try { - final result = await getSubscriptionByIdUsecase(event.subscriptionId); + final cardsResult = await getPaymentCardsUsecase(); + + final subsResult = await getSubscriptionByIdUsecase( + event.subscriptionId, + ); final clientSubsResult = await getClientSubscriptionsUsecase(); bool isPurchased = false; switch (clientSubsResult) { case Success>(): - isPurchased = clientSubsResult.data?.any( + isPurchased = + clientSubsResult.data?.any( (element) => element.subscriptionId == event.subscriptionId, - ) ?? false; + ) ?? + false; break; case Failure>(): @@ -40,16 +50,30 @@ class SubscriptionDetailsBloc break; } - switch (result) { + switch (subsResult) { case Success(): - final sub = result.data; + final sub = subsResult.data; if (sub == null) return; + List loadedCards = []; + PaymentCard? defaultCard; + + if (cardsResult is Success>) { + loadedCards = cardsResult.data ?? []; + if (loadedCards.isNotEmpty) { + defaultCard = loadedCards.firstWhere( + (element) => element.isMain, + ); + } + } + emit( DetailsContentState( subscription: sub, selectedPeriod: sub.options.first, isAlreadyPurchased: isPurchased, + cards: loadedCards, + selectedCard: defaultCard ?? loadedCards.first, ), ); break; @@ -70,6 +94,12 @@ class SubscriptionDetailsBloc } }); + on((event, emit) { + if (state is DetailsContentState) { + emit((state as DetailsContentState).copyWith(selectedCard: event.card)); + } + }); + on((event, emit) { if (state is DetailsContentState) { emit((state as DetailsContentState).copyWith(isAgreed: event.value)); @@ -79,16 +109,18 @@ class SubscriptionDetailsBloc on((event, emit) async { switch (state) { case DetailsContentState contentState: - if (!contentState.isAgreed) { + if (!contentState.isAgreed || contentState.selectedCard == null) { return; } - final result = await activateSubscriptionUsecase(contentState.selectedPeriod.id); + final result = await activateSubscriptionUsecase( + contentState.selectedPeriod.id, + contentState.selectedCard.id, + ); if (result is Success) { emit(contentState.copyWith(isSuccess: true)); - } else { - } + } else {} break; default: break; diff --git a/lib/presentation/viewmodel/top_up_bloc.dart b/lib/presentation/viewmodel/top_up_bloc.dart index 4ef9799..19d6bb0 100644 --- a/lib/presentation/viewmodel/top_up_bloc.dart +++ b/lib/presentation/viewmodel/top_up_bloc.dart @@ -18,11 +18,11 @@ class TopUpBloc extends Bloc { required this.getCertificatesUsecase, required this.purchaseCertificateUsecase, required this.getUserCards, - }) : super(TopUpState(isLoading: true)) { - on((event, emit) async { - emit(state.copyWith(isLoading: true)); + }) : super(const TopUpLoading()) { + + on((event, emit) async { + emit(const TopUpLoading()); - // Запускаем оба запроса параллельно final results = await Future.wait([ getCertificatesUsecase(), getUserCards(), @@ -34,48 +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) { + 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 { + if (state is TopUpLoaded) { + final currentState = state as TopUpLoaded; + + if (currentState.isAgreed && + currentState.selectedTariff != null && + currentState.selectedCard != null) { + + 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('Ошибка при оплате')); + } + } } }); - 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)), - ); } -} +} \ 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 5b7f9fb..f5cdb00 100644 --- a/lib/presentation/viewmodel/verify_code_bloc.dart +++ b/lib/presentation/viewmodel/verify_code_bloc.dart @@ -1,19 +1,35 @@ 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'; + +import 'dart:async'; + import 'package:be_happy/core/failures.dart'; import 'package:be_happy/core/result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/usecase/verify_code_usecase.dart'; +import '../../domain/usecase/login_usecase.dart'; // 🔹 Импортируем LoginUseCase import '../event/verify_code_event.dart'; import '../state/verify_code_state.dart'; class VerifyCodeBloc extends Bloc { final VerifyCodeUseCase _verifyCodeUseCase; + final LoginUseCase _loginUseCase; + final SplashBloc _splashBloc; Timer? _timer; - VerifyCodeBloc(this._verifyCodeUseCase) + VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase, this._splashBloc) : super(VerifyCodeState.initial()) { + on((event, emit) { emit(state.copyWith( phoneNumber: event.phoneNumber, @@ -24,56 +40,91 @@ class VerifyCodeBloc extends Bloc { }); on((event, emit) { - emit(state.copyWith(code: event.code)); + emit(state.copyWith( + code: event.code, + error: null, + )); }); - on((event, emit) { - if (state.secondsLeft == 0) { - emit(state.copyWith(secondsLeft: 60)); - _startTimer(); + on((event, emit) async { + if (state.secondsLeft == 0 && !state.isSubmitting) { + emit(state.copyWith(isSubmitting: true, error: null)); + + try { + final newTempToken = await _loginUseCase.execute("+${state.phoneNumber}"); + + emit(state.copyWith( + isSubmitting: false, + tempToken: newTempToken, + secondsLeft: 60, + code: '', + error: null, + isBlocked: false, + )); + + _startTimer(); + } catch (e) { + emit(state.copyWith( + isSubmitting: false, + error: 'Не удалось повторно отправить код. Попробуйте позже.', + )); + } } }); + on((event, emit) { + emit(state.copyWith(secondsLeft: event.secondsLeft)); + }); + on(_onVerifyCodeSubmitted); } void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (state.secondsLeft <= 1) { + final currentSeconds = state.secondsLeft; + if (currentSeconds <= 1) { timer.cancel(); - emit(state.copyWith(secondsLeft: 0)); + add(TimerTicked(0)); } else { - emit(state.copyWith(secondsLeft: state.secondsLeft - 1)); + add(TimerTicked(currentSeconds - 1)); } }); } Future _onVerifyCodeSubmitted(VerifyCodeSubmitted event, Emitter emit,) async { - if (state.isSubmitting || state.code.length != 6) return; + if (state.isSubmitting || state.code.length != 6 || state.secondsLeft == 0) return; emit(state.copyWith(isSubmitting: true, error: null)); final result = await _verifyCodeUseCase.execute(state.code, state.tempToken); final newState = switch (result) { - Success() => state.copyWith( - isSubmitting: false, - isSuccess: true, - ), + Success() => (() { + _timer?.cancel(); + + _splashBloc.add(AuthCheckRequested()); + + return state.copyWith( + isSubmitting: false, + isSuccess: true, + ); + })(), Failure(failure: final f) => switch (f) { AuthFailure(attemptsLeft: final count) => state.copyWith( isSubmitting: false, attemptsLeft: count, error: 'Неверный код. Осталось попыток: $count', ), - AuthBlockFailure() => state.copyWith( - isSubmitting: false, - isBlocked: true, - error: 'Вы заблокированы за слишком частые попытки', - ), + AuthBlockFailure() => (() { + _timer?.cancel(); + return state.copyWith( + isSubmitting: false, + isBlocked: true, + error: 'Вы заблокированы за слишком частые попытки', + ); + })(), _ => state.copyWith( isSubmitting: false, error: 'Произошла ошибка. Попробуйте позже', @@ -89,4 +140,4 @@ class VerifyCodeBloc extends Bloc { _timer?.cancel(); return super.close(); } -} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2543c04..7a88613 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -524,26 +524,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -572,26 +572,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: "direct main" description: @@ -817,10 +817,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.11" typed_data: dependency: transitive description: @@ -905,10 +905,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -974,5 +974,5 @@ packages: source: hosted version: "4.2.1" sdks: - dart: ">=3.8.1 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index afa186d..93647cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: be_happy description: "" publish_to: 'none' -version: 1.0.0+12 +version: 1.0.0+13 environment: sdk: ^3.8.1