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/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..51f7874 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) { @@ -177,13 +181,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'], ); }), ); @@ -517,13 +517,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 +539,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); 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/di/service_locator.dart b/lib/di/service_locator.dart index 1198bff..a7e3a02 100644 --- a/lib/di/service_locator.dart +++ b/lib/di/service_locator.dart @@ -305,7 +305,7 @@ Future setupDependencies() async { getIt.registerFactory(() => PhoneAuthBloc(getIt())); - getIt.registerFactory(() => VerifyCodeBloc(getIt())); + getIt.registerFactory(() => VerifyCodeBloc(getIt(), getIt())); getIt.registerFactory(() => ProfileBloc(getIt(), getIt(), getIt())); @@ -337,7 +337,7 @@ Future setupDependencies() async { getIt.registerFactory( - () => PaymentMethodSheetBloc(getIt()), + () => PaymentMethodSheetBloc(getIt(), getIt()), ); getIt.registerFactory(() => CurrentRidesBloc(getIt())); 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/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/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/presentation/components/scooter/scooter_info_section.dart b/lib/presentation/components/scooter/scooter_info_section.dart index 0d94689..fb6e914 100644 --- a/lib/presentation/components/scooter/scooter_info_section.dart +++ b/lib/presentation/components/scooter/scooter_info_section.dart @@ -2,18 +2,22 @@ import 'package:flutter/material.dart'; import 'scooter_info_item.dart'; class ScooterInfoSection extends StatelessWidget { - const ScooterInfoSection({super.key}); + final double distance; + final String location; + const ScooterInfoSection({super.key, required this.distance, required this.location}); @override Widget build(BuildContext context) { return Column( - children: const [ + children: [ ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'), ScooterInfoItem(icon: 'assets/icons/speed.png', text: 'max = 25 км/ч'), - ScooterInfoItem(icon: 'assets/icons/location.png', text: 'пр. Московский, 33'), + ScooterInfoItem(icon: 'assets/icons/location.png', text: location), Row( children: [ - ScooterInfoItem(icon: 'assets/icons/person.png', text: '120 м'), + ScooterInfoItem(icon: 'assets/icons/person.png', text: distance.toInt() >= 1000 + ? '${((distance.toInt()) / 1000).toStringAsFixed(1)} km' + : '${distance.toInt()} m',), SizedBox(width: 16), ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'), ], diff --git a/lib/presentation/components/scooter_bottom_sheet.dart b/lib/presentation/components/scooter_bottom_sheet.dart index 5c749ce..5d50591 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); }, 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); }, ), ), diff --git a/lib/presentation/components/sheet/payment_method_sheet.dart b/lib/presentation/components/sheet/payment_method_sheet.dart index f30365d..30c6062 100644 --- a/lib/presentation/components/sheet/payment_method_sheet.dart +++ b/lib/presentation/components/sheet/payment_method_sheet.dart @@ -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..c02ec01 100644 --- a/lib/presentation/components/sheet/tariff_sheet.dart +++ b/lib/presentation/components/sheet/tariff_sheet.dart @@ -12,6 +12,7 @@ import '../../../domain/entities/payment_card.dart'; import '../../../domain/entities/scooter.dart'; import '../../../domain/entities/tariff.dart'; import '../../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../../domain/usecase/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'; @@ -288,10 +289,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 +302,7 @@ class _TariffSheetState extends State { builder: (innerContext) => BlocProvider( create: (context) => PaymentMethodSheetBloc( getIt(), + getIt(), )..add(PaymentMethodSheetStarted()), child: PaymentMethodSheet( initialSelectedCard: state.useBalance ? null : state.selectedCard, @@ -381,7 +383,6 @@ class _TariffSheetState extends State { const SizedBox(height: 12), - // 🔹 КНОПКА "ЗАБРОНИРОВАТЬ" Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: GradientButton( @@ -494,13 +495,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 +538,6 @@ class _TariffCard extends StatelessWidget { ], ), - // Кнопка-иконка в верхнем правом углу Positioned( top: 0, right: 0, diff --git a/lib/presentation/components/side_menu.dart b/lib/presentation/components/side_menu.dart index 3e5c668..f6cb3d1 100644 --- a/lib/presentation/components/side_menu.dart +++ b/lib/presentation/components/side_menu.dart @@ -121,10 +121,10 @@ class SideMenu extends StatelessWidget { _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/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/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..2d83b0e 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,8 @@ class AppRouter { type: MaterialType.transparency, child: BlocProvider( create: (context) => - PaymentMethodSheetBloc(getIt()), + PaymentMethodSheetBloc(getIt(), + getIt()), child: PaymentMethodSheet(), ), ), @@ -297,12 +321,14 @@ class AppRouter { ], ), GoRoute( - path: 'scooter/:id', - builder: (context, state) => - BlocProvider( + path: 'scooter/:id', + builder: (context, state) { + String address = state.extra as String; + return BlocProvider( create: (context) => getIt(), - child: ScooterDetailScreen(), - ), + child: ScooterDetailScreen(address: address), + ); + } ), GoRoute( path: 'order-photos/:orderId', @@ -371,6 +397,7 @@ class AppRouter { create: (context) => SubscriptionDetailsBloc( getIt(), + getIt(), getIt(), getIt(), ) @@ -394,24 +421,26 @@ 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', @@ -423,6 +452,7 @@ class AppRouter { getIt(), getIt(), getIt(), + getIt(), ) ..add(PaymentMethodsStarted()), child: PaymentMethodsScreen(), @@ -454,9 +484,9 @@ class AppRouter { ], ), GoRoute( - path: 'order-history', - builder: (context, state) => const OrderHistoryScreen(), - routes: [] + path: 'order-history', + builder: (context, state) => const OrderHistoryScreen(), + routes: [] ), GoRoute( path: 'notifications', @@ -466,6 +496,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) { 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..becb7f5 --- /dev/null +++ b/lib/presentation/screens/card_binding_result_screen.dart @@ -0,0 +1,165 @@ +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) { + context.go('/home/payment-methods'); + } + }); + } + } + + @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: () {}, + enabled: true, + showArrows: true, + ), + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + height: 56, + child: TextButton( + onPressed: () => context.go("/home/payment-methods"), + 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/payment_confirm_screen.dart b/lib/presentation/screens/payment_confirm_screen.dart index b8aafa1..eeeedcf 100644 --- a/lib/presentation/screens/payment_confirm_screen.dart +++ b/lib/presentation/screens/payment_confirm_screen.dart @@ -5,6 +5,7 @@ import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../components/custom_app_bar.dart'; import '../components/gradient_button.dart'; import '../components/payment_option.dart'; @@ -212,10 +213,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 +226,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget { builder: (innerContext) => BlocProvider( create: (context) => PaymentMethodSheetBloc( 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..7894919 100644 --- a/lib/presentation/screens/payment_methods_screen.dart +++ b/lib/presentation/screens/payment_methods_screen.dart @@ -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..d0a13f6 100644 --- a/lib/presentation/screens/phone_login_screen.dart +++ b/lib/presentation/screens/phone_login_screen.dart @@ -79,17 +79,18 @@ class _PhoneLoginScreenState extends 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..deeeed0 100644 --- a/lib/presentation/screens/scooter_detail_screen.dart +++ b/lib/presentation/screens/scooter_detail_screen.dart @@ -12,7 +12,8 @@ import '../state/scooter_detail_state.dart'; import '../viewmodel/scooter_detail_bloc.dart'; class ScooterDetailScreen extends StatelessWidget { - const ScooterDetailScreen({super.key}); + final String address; + const ScooterDetailScreen({super.key, required this.address}); @override Widget build(BuildContext context) { @@ -83,7 +84,7 @@ class ScooterDetailScreen extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Expanded(child: ScooterInfoSection()), + Expanded(child: ScooterInfoSection(distance: scooter?.distance ?? 0, location: address)), const SizedBox(width: 20), ], ), diff --git a/lib/presentation/screens/subscription_details_screen.dart b/lib/presentation/screens/subscription_details_screen.dart index 4481c3b..20d54e2 100644 --- a/lib/presentation/screens/subscription_details_screen.dart +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -3,13 +3,21 @@ 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/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 +58,9 @@ class SubscriptionDetailsScreen extends StatelessWidget { ), const SizedBox(height: 20), - // 🔹 Контент Expanded( child: Stack( children: [ - // Волна снизу Positioned( bottom: 0, left: 0, @@ -108,10 +114,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 +126,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 +142,7 @@ class SubscriptionDetailsScreen extends StatelessWidget { ActivateSubscriptionPressed(), ); }, - enabled: state.isAgreed, + enabled: state.isAgreed && state.selectedCard != null, width: double.infinity, height: 56, fontSize: 16, @@ -141,8 +154,77 @@ 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(), + )..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 +298,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..bcd5e50 100644 --- a/lib/presentation/screens/top_up_screen.dart +++ b/lib/presentation/screens/top_up_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:path/path.dart'; import '../../core/app_colors.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/get_payment_cards_usecase.dart'; +import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../components/app_checkbox.dart'; import '../components/custom_app_bar.dart'; // ✅ Уже есть import '../components/gradient_button.dart'; @@ -22,54 +24,56 @@ class TopUpScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: Container( - decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 16), - const CustomAppBar(title: 'Пополнение баланса'), - const SizedBox(height: 20), + decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 16), + const CustomAppBar(title: 'Пополнение баланса'), + const SizedBox(height: 20), - BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } - return Expanded( - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTariffList(state, context), - const SizedBox(height: 30), - _buildPriceInfo(state), - const SizedBox(height: 40), - const Text( - 'Способ оплаты', - style: TextStyle(color: Colors.white70), - ), - const SizedBox(height: 15), - _buildCardSelector(state, context), - const SizedBox(height: 20), - _buildAgreement(state, context), - const Spacer(), - _buildPayButton(state), - const SizedBox(height: 30), - ], - ), - ], - ), - ); - }, - ), - ], + return Expanded( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTariffList(state, context), + const SizedBox(height: 30), + _buildPriceInfo(state), + const SizedBox(height: 40), + const Text( + 'Способ оплаты', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 15), + _buildCardSelector(state, context), + const SizedBox(height: 20), + _buildAgreement(state, context), + const Spacer(), + _buildPayButton(state, () { + context.read().add(TopUpButtonPressed()); + }), + const SizedBox(height: 30), + ], + ), + ], + ), + ); + }, + ), + ], + ), ), - ), - ) + ) ), ); } @@ -158,20 +162,24 @@ class TopUpScreen extends StatelessWidget { ? 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( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (innerContext) => BlocProvider( - create: (context) => - PaymentMethodSheetBloc(getIt()) - ..add(PaymentMethodSheetStarted()), - child: const PaymentMethodSheet(showBalance: false,), - ), + builder: (innerContext) => + BlocProvider( + create: (context) => + PaymentMethodSheetBloc( + getIt(), + getIt(), + ) + ..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(showBalance: false,), + ), ); if (selectedCard != null) { @@ -260,10 +268,10 @@ class TopUpScreen extends StatelessWidget { ); } - Widget _buildPayButton(TopUpState state) { + Widget _buildPayButton(TopUpState state, VoidCallback onTap) { return GradientButton( text: 'Оплатить', - onTap: state.isAgreed ? () {} : null, + onTap: state.isAgreed ? onTap : null, enabled: state.isAgreed, showArrows: true, height: 56, 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/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/map_bloc.dart b/lib/presentation/viewmodel/map_bloc.dart index 72268d0..e8bf450 100644 --- a/lib/presentation/viewmodel/map_bloc.dart +++ b/lib/presentation/viewmodel/map_bloc.dart @@ -264,8 +264,9 @@ class MapBloc extends Bloc { FutureOr _onLogoutPressed( LogoutPressed event, Emitter emit, - ) { - logoutUseCase(); + ) async { + await logoutUseCase(); + stopNotificationStream(); splashBloc.add(AuthCheckRequested()); } diff --git a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart index 77069f3..97e09f3 100644 --- a/lib/presentation/viewmodel/payment_method_sheet_bloc.dart +++ b/lib/presentation/viewmodel/payment_method_sheet_bloc.dart @@ -3,13 +3,15 @@ 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/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 SetMainPaymentCardUsecase _setMainPaymentCardUsecase; - PaymentMethodSheetBloc(this._getPaymentCardsUsecase) + PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase) : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { on(_onStarted); } @@ -41,4 +43,53 @@ class PaymentMethodSheetBloc extends Bloc _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, + 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(), + )); + } + } } diff --git a/lib/presentation/viewmodel/payment_methods_bloc.dart b/lib/presentation/viewmodel/payment_methods_bloc.dart index 7ac3819..861ac01 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( @@ -136,4 +143,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/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..c46fbf2 100644 --- a/lib/presentation/viewmodel/top_up_bloc.dart +++ b/lib/presentation/viewmodel/top_up_bloc.dart @@ -22,7 +22,6 @@ class TopUpBloc extends Bloc { on((event, emit) async { emit(state.copyWith(isLoading: true)); - // Запускаем оба запроса параллельно final results = await Future.wait([ getCertificatesUsecase(), getUserCards(), @@ -77,5 +76,21 @@ class TopUpBloc extends Bloc { on( (event, emit) => emit(state.copyWith(isAgreed: event.value)), ); + on((event, emit) async { + emit(state.copyWith(isLoading: true)); + + if (state.isAgreed) { + final result = await purchaseCertificateUsecase( + certificateId: state.selectedTariff!.id, + cardId:state.selectedCard!.id, + ); + + if (result is Success) { + emit(state.copyWith(isLoading: false)); + } + } + + + }); } } diff --git a/lib/presentation/viewmodel/verify_code_bloc.dart b/lib/presentation/viewmodel/verify_code_bloc.dart index 5b7f9fb..a97433d 100644 --- a/lib/presentation/viewmodel/verify_code_bloc.dart +++ b/lib/presentation/viewmodel/verify_code_bloc.dart @@ -8,12 +8,26 @@ import '../../domain/usecase/verify_code_usecase.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; // 🔹 Добавляем поле для юзкейса логина Timer? _timer; - VerifyCodeBloc(this._verifyCodeUseCase) + // 🔹 Обновляем конструктор + VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase) : super(VerifyCodeState.initial()) { + on((event, emit) { emit(state.copyWith( phoneNumber: event.phoneNumber, @@ -24,56 +38,89 @@ 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)); + // 🔹 Здесь автоматически подставится обновленный из стейта tempToken final result = await _verifyCodeUseCase.execute(state.code, state.tempToken); final newState = switch (result) { - Success() => state.copyWith( - isSubmitting: false, - isSuccess: true, - ), + Success() => (() { + _timer?.cancel(); + 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 +136,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 72cc1fc..09ed118 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 0128c1f..bd315a6 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