This commit is contained in:
2026-07-08 13:09:20 +03:00
parent b9da1d3064
commit 9eda119527
42 changed files with 830 additions and 279 deletions

View File

@@ -8,6 +8,7 @@
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent> </intent>
<intent> <intent>
@@ -49,6 +50,14 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter android:label="card_binding_deep_link">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.behappy.app" android:host="card-result" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
yandexMapkit.variant=full 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
assets/icons/failed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
assets/icons/success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -2,7 +2,7 @@ import '../../domain/entities/payment_card.dart';
class PaymentCardResponseDto { class PaymentCardResponseDto {
final int id; final int id;
final int clientId; final String bindingId;
final int expirationMonth; final int expirationMonth;
final int expirationYear; final int expirationYear;
final String cardHolder; final String cardHolder;
@@ -12,7 +12,7 @@ class PaymentCardResponseDto {
PaymentCardResponseDto({ PaymentCardResponseDto({
required this.id, required this.id,
required this.clientId, required this.bindingId,
required this.expirationMonth, required this.expirationMonth,
required this.expirationYear, required this.expirationYear,
required this.cardHolder, required this.cardHolder,
@@ -24,7 +24,7 @@ class PaymentCardResponseDto {
factory PaymentCardResponseDto.fromJson(Map<String, dynamic> json) { factory PaymentCardResponseDto.fromJson(Map<String, dynamic> json) {
return PaymentCardResponseDto( return PaymentCardResponseDto(
id: json['id'] as int, id: json['id'] as int,
clientId: json['clientId'] as int, bindingId: json['bindingId'] as String,
expirationMonth: json['expirationMonth'] as int, expirationMonth: json['expirationMonth'] as int,
expirationYear: json['expirationYear'] as int, expirationYear: json['expirationYear'] as int,
cardHolder: json['cardHolder'] as String, cardHolder: json['cardHolder'] as String,
@@ -37,13 +37,9 @@ class PaymentCardResponseDto {
PaymentCard toEntity(String? fullCardNumber) { PaymentCard toEntity(String? fullCardNumber) {
return PaymentCard( return PaymentCard(
id: id, id: id,
clientId: clientId, bindingId: bindingId,
expirationMonth: expirationMonth,
expirationYear: expirationYear,
cardHolder: cardHolder,
cardLastNumber: cardLastNumber, cardLastNumber: cardLastNumber,
isMain: isMain, isMain: isMain,
type: type,
); );
} }
} }

View File

@@ -52,7 +52,11 @@ class ApiService {
try { try {
final response = await _dio.post( final response = await _dio.post(
"$baseUrl/auth/client/login", "$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) { if (response.statusCode == 200 || response.statusCode == 201) {
@@ -177,13 +181,9 @@ class ApiService {
cardsJson.map((cardJson) async { cardsJson.map((cardJson) async {
return PaymentCard( return PaymentCard(
id: cardJson['id'], id: cardJson['id'],
clientId: cardJson['clientId'], bindingId: cardJson['bindingId'],
expirationMonth: cardJson['expirationMonth'],
expirationYear: cardJson['expirationYear'],
cardHolder: cardJson['cardHolder'],
cardLastNumber: cardJson['cardLastNumber'], cardLastNumber: cardJson['cardLastNumber'],
isMain: cardJson['isMain'], isMain: cardJson['isMain'],
type: cardJson['cardType'],
); );
}), }),
); );
@@ -517,13 +517,13 @@ class ApiService {
} }
} }
Future<bool> activateSubscription({required int optionId}) async { Future<bool> activateSubscription({required int optionId, required int cardId}) async {
const url = "$baseUrl/scootersubscription/client"; const url = "$baseUrl/scootersubscription/client";
try { try {
final response = await _dio.post( final response = await _dio.post(
url, url,
data: {"optionId": optionId}, data: {"optionId": optionId, "cardId": cardId},
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );
@@ -539,31 +539,25 @@ class ApiService {
} }
} }
Future<void> addPaymentCard({ Future<Map<String, dynamic>> addPaymentCard() async {
required String cardNumber,
required String cardHolder,
required int expirationMonth,
required int expirationYear,
required String cvv,
}) async {
const url = "$baseUrl/client/card"; const url = "$baseUrl/client/card";
final cleanCardNumber = cardNumber.replaceAll(' ', ''); /*final cleanCardNumber = cardNumber.replaceAll(' ', '');*/
try { try {
final response = await _dio.post( final response = await _dio.post(
url, url,
data: { /*data: {
"cardNumber": cleanCardNumber, "cardNumber": cleanCardNumber,
"cardHolder": cardHolder, "cardHolder": cardHolder,
"expirationMonth": expirationMonth, "expirationMonth": expirationMonth,
"expirationYear": expirationYear, "expirationYear": expirationYear,
"cvv": cvv, "cvv": cvv,
}, },*/
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return; return response.data;
} }
throw AuthException('Непредвиденный статус: ${response.statusCode}', 0); throw AuthException('Непредвиденный статус: ${response.statusCode}', 0);

View File

@@ -32,25 +32,25 @@ class PaymentRepositoryImpl implements PaymentRepository {
} }
@override @override
Future<Result<void>> addPaymentCard({ Future<Result<Map<String, dynamic>>> addPaymentCard(
required String cardNumber, /*required String cardNumber,
required String cardHolder, required String cardHolder,
required String expiryMonth, required String expiryMonth,
required String expiryYear, required String expiryYear,
required String cvv, required String cvv,*/
}) async { ) async {
try { try {
await apiService.addPaymentCard( final result = await apiService.addPaymentCard(
cardNumber: cardNumber, /*cardNumber: cardNumber,
cardHolder: cardHolder, cardHolder: cardHolder,
expirationMonth: int.parse(expiryMonth), expirationMonth: int.parse(expiryMonth),
expirationYear: int.parse(expiryYear), expirationYear: int.parse(expiryYear),
cvv: cvv, cvv: cvv,*/
); );
// await securityService.saveCardFullNumber(cardId, cardNumber); // await securityService.saveCardFullNumber(cardId, cardNumber);
return Success(null); return Success(result);
} on AuthException catch (e) { } on AuthException catch (e) {
return Failure(AuthFailure(e.attemptsLeft)); return Failure(AuthFailure(e.attemptsLeft));
} on AuthBlockException catch (_) { } on AuthBlockException catch (_) {
@@ -98,9 +98,9 @@ class PaymentRepositoryImpl implements PaymentRepository {
} }
@override @override
Future<Result<bool>> activateSubscription(int optionId) async { Future<Result<bool>> activateSubscription(int optionId, int cardId) async {
try { try {
final success = await apiService.activateSubscription(optionId: optionId); final success = await apiService.activateSubscription(optionId: optionId, cardId: cardId);
return Success(success); return Success(success);
} on AuthException catch (e) { } on AuthException catch (e) {
return Failure(AuthFailure(e.attemptsLeft)); return Failure(AuthFailure(e.attemptsLeft));

View File

@@ -305,7 +305,7 @@ Future<void> setupDependencies() async {
getIt.registerFactory<PhoneAuthBloc>(() => PhoneAuthBloc(getIt())); getIt.registerFactory<PhoneAuthBloc>(() => PhoneAuthBloc(getIt()));
getIt.registerFactory<VerifyCodeBloc>(() => VerifyCodeBloc(getIt())); getIt.registerFactory<VerifyCodeBloc>(() => VerifyCodeBloc(getIt(), getIt()));
getIt.registerFactory<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt())); getIt.registerFactory<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt()));
@@ -337,7 +337,7 @@ Future<void> setupDependencies() async {
getIt.registerFactory<PaymentMethodSheetBloc>( getIt.registerFactory<PaymentMethodSheetBloc>(
() => PaymentMethodSheetBloc(getIt()), () => PaymentMethodSheetBloc(getIt(), getIt()),
); );
getIt.registerFactory<CurrentRidesBloc>(() => CurrentRidesBloc(getIt())); getIt.registerFactory<CurrentRidesBloc>(() => CurrentRidesBloc(getIt()));

View File

@@ -1,43 +1,27 @@
class PaymentCard { class PaymentCard {
final int id; final int id;
final int clientId; final String? bindingId;
final int expirationMonth;
final int expirationYear;
final String cardHolder;
final String cardLastNumber; final String cardLastNumber;
final bool isMain; final bool isMain;
final String type;
PaymentCard({ PaymentCard({
required this.id, required this.id,
required this.clientId, required this.bindingId,
required this.expirationMonth,
required this.expirationYear,
required this.cardHolder,
required this.cardLastNumber, required this.cardLastNumber,
required this.isMain, required this.isMain,
required this.type,
}); });
PaymentCard copyWith({ PaymentCard copyWith({
int? id, int? id,
int? clientId, String? bindingId,
int? expirationMonth,
int? expirationYear,
String? cardHolder,
String? cardLastNumber, String? cardLastNumber,
bool? isMain, bool? isMain,
String? type,
}) { }) {
return PaymentCard( return PaymentCard(
id: id ?? this.id, id: id ?? this.id,
clientId: clientId ?? this.clientId, bindingId: bindingId ?? this.bindingId,
expirationMonth: expirationMonth ?? this.expirationMonth,
expirationYear: expirationYear ?? this.expirationYear,
cardHolder: cardHolder ?? this.cardHolder,
cardLastNumber: cardLastNumber ?? this.cardLastNumber, cardLastNumber: cardLastNumber ?? this.cardLastNumber,
isMain: isMain ?? this.isMain, isMain: isMain ?? this.isMain,
type: type ?? this.type,
); );
} }
} }

View File

@@ -4,17 +4,17 @@ import '../entities/payment_card.dart';
abstract class PaymentRepository { abstract class PaymentRepository {
Future<Result<List<PaymentCard>>> getPaymentCards(); Future<Result<List<PaymentCard>>> getPaymentCards();
Future<Result<void>> addPaymentCard({ Future<Result<Map<String, dynamic>>> addPaymentCard(
required String cardNumber, /*required String cardNumber,
required String cardHolder, required String cardHolder,
required String expiryMonth, required String expiryMonth,
required String expiryYear, required String expiryYear,
required String cvv, required String cvv,*/
}); );
Future<Result<void>> setMainPaymentCard(int cardId); Future<Result<void>> setMainPaymentCard(int cardId);
Future<Result<void>> removePaymentCard(int cardId); Future<Result<void>> removePaymentCard(int cardId);
Future<Result<bool>> activateSubscription(int optionId); Future<Result<bool>> activateSubscription(int optionId, int cardId);
} }

View File

@@ -6,7 +6,7 @@ class ActivateSubscriptionUsecase {
ActivateSubscriptionUsecase(this.repository); ActivateSubscriptionUsecase(this.repository);
Future<Result<bool>> call(int optionId) { Future<Result<bool>> call(int optionId, int cardId) {
return repository.activateSubscription(optionId); return repository.activateSubscription(optionId, cardId);
} }
} }

View File

@@ -6,19 +6,19 @@ class AddPaymentCardUsecase {
AddPaymentCardUsecase(this.repository); AddPaymentCardUsecase(this.repository);
Future<Result<void>> call({ Future<Result<Map<String, dynamic>>> call(
required String cardNumber, /*required String cardNumber,
required String cardHolder, required String cardHolder,
required String expiryMonth, required String expiryMonth,
required String expiryYear, required String expiryYear,
required String cvv, required String cvv,*/
}) { ) {
return repository.addPaymentCard( return repository.addPaymentCard(
cardNumber: cardNumber, /*cardNumber: cardNumber,
cardHolder: cardHolder, cardHolder: cardHolder,
expiryMonth: expiryMonth, expiryMonth: expiryMonth,
expiryYear: expiryYear, expiryYear: expiryYear,
cvv: cvv, cvv: cvv,*/
); );
} }
} }

View File

@@ -2,18 +2,22 @@ import 'package:flutter/material.dart';
import 'scooter_info_item.dart'; import 'scooter_info_item.dart';
class ScooterInfoSection extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: const [ children: [
ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'), ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'),
ScooterInfoItem(icon: 'assets/icons/speed.png', text: 'max = 25 км/ч'), 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( Row(
children: [ 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), SizedBox(width: 16),
ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'), ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'),
], ],

View File

@@ -128,6 +128,7 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
child: _ScooterCard( child: _ScooterCard(
scooter: scooter, scooter: scooter,
isActive: isActive, isActive: isActive,
address: state.address ?? "Unknown address",
), ),
), ),
), ),
@@ -151,8 +152,9 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
class _ScooterCard extends StatelessWidget { class _ScooterCard extends StatelessWidget {
final Scooter scooter; final Scooter scooter;
final bool isActive; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -186,7 +188,9 @@ class _ScooterCard extends StatelessWidget {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( 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), style: const TextStyle(color: Colors.white, fontSize: 14),
), ),
], ],
@@ -229,7 +233,7 @@ class _ScooterCard extends StatelessWidget {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
context.go('/home/scooter/${scooter.id}'); context.go('/home/scooter/${scooter.id}', extra: address);
}, },
child: Container( child: Container(
height: 42, height: 42,
@@ -241,7 +245,7 @@ class _ScooterCard extends StatelessWidget {
width: double.infinity, width: double.infinity,
fontSize: 12, fontSize: 12,
onTap: () { onTap: () {
context.go('/home/scooter/${scooter.id}'); context.go('/home/scooter/${scooter.id}', extra: address);
}, },
), ),
), ),

View File

@@ -192,13 +192,18 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: PaymentOption( child: PaymentOption(
title: card.type, title: card.isMain ? "Основная" : "",
subtitle: '****${card.cardLastNumber}', subtitle: '${card.cardLastNumber}',
isSelected: _selectedPaymentMethod == index, isSelected: _selectedPaymentMethod == index,
onTap: () { onTap: () async {
setState(() { setState(() {
_selectedPaymentMethod = index; _selectedPaymentMethod = index;
}); });
context.read<PaymentMethodSheetBloc>().add(
SelectPaymentCardRequested(card),
);
Navigator.pop(context, card); Navigator.pop(context, card);
}, },
), ),

View File

@@ -151,6 +151,7 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
child: _ScooterCard( child: _ScooterCard(
scooter: scooter, scooter: scooter,
isActive: isActive, isActive: isActive,
address: state.address ?? "Unknown address",
), ),
), ),
), ),
@@ -174,9 +175,10 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
class _ScooterCard extends StatelessWidget { class _ScooterCard extends StatelessWidget {
final Scooter scooter; final Scooter scooter;
final String address;
final bool isActive; final bool isActive;
const _ScooterCard({required this.scooter, required this.isActive}); const _ScooterCard({required this.scooter, required this.isActive, required this.address});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -210,7 +212,9 @@ class _ScooterCard extends StatelessWidget {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( 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), style: const TextStyle(color: Colors.white, fontSize: 14),
), ),
], ],
@@ -262,7 +266,7 @@ class _ScooterCard extends StatelessWidget {
width: double.infinity, width: double.infinity,
fontSize: 12, fontSize: 12,
onTap: () { onTap: () {
Navigator.pop(context, scooter); Navigator.pop(context, [scooter, address]);
}, },
), ),
], ],

View File

@@ -12,6 +12,7 @@ import '../../../domain/entities/payment_card.dart';
import '../../../domain/entities/scooter.dart'; import '../../../domain/entities/scooter.dart';
import '../../../domain/entities/tariff.dart'; import '../../../domain/entities/tariff.dart';
import '../../../domain/usecase/get_payment_cards_usecase.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/payment_method_sheet_event.dart';
import '../../event/tariff_sheet_event.dart'; import '../../event/tariff_sheet_event.dart';
import '../../state/tariff_sheet_state.dart'; import '../../state/tariff_sheet_state.dart';
@@ -288,10 +289,10 @@ class _TariffSheetState extends State<TariffSheet> {
child: PaymentOption( child: PaymentOption(
title: state.useBalance title: state.useBalance
? 'Баланс' ? 'Баланс'
: state.selectedCard!.type, : "Карта",
subtitle: state.useBalance subtitle: state.useBalance
? '${state.userBalance.toStringAsFixed(2)} BYN' ? '${state.userBalance.toStringAsFixed(2)} BYN'
: '****${state.selectedCard!.cardLastNumber}', : '${state.selectedCard!.cardLastNumber}',
isSelected: true, isSelected: true,
onTap: () async { onTap: () async {
final result = await showModalBottomSheet<dynamic>( final result = await showModalBottomSheet<dynamic>(
@@ -301,6 +302,7 @@ class _TariffSheetState extends State<TariffSheet> {
builder: (innerContext) => BlocProvider( builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc( create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(), getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()), )..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet( child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard, initialSelectedCard: state.useBalance ? null : state.selectedCard,
@@ -381,7 +383,6 @@ class _TariffSheetState extends State<TariffSheet> {
const SizedBox(height: 12), const SizedBox(height: 12),
// 🔹 КНОПКА "ЗАБРОНИРОВАТЬ"
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: GradientButton( child: GradientButton(
@@ -494,13 +495,10 @@ class _TariffCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
// Резервируем место под иконку инфо справа,
// чтобы текст не залез под неё
const SizedBox(width: 24), const SizedBox(width: 24),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Цена + текст рядом
Row( Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic, textBaseline: TextBaseline.alphabetic,
@@ -540,7 +538,6 @@ class _TariffCard extends StatelessWidget {
], ],
), ),
// Кнопка-иконка в верхнем правом углу
Positioned( Positioned(
top: 0, top: 0,
right: 0, right: 0,

View File

@@ -121,10 +121,10 @@ class SideMenu extends StatelessWidget {
_MenuItem( _MenuItem(
icon: 'assets/icons/logout_icon.png', icon: 'assets/icons/logout_icon.png',
title: 'Выход', title: 'Выход',
onTap: () => { onTap: () {
context.go('/login'), context.read<MapBloc>().stopNotificationStream();
context.read<MapBloc>().stopNotificationStream(), context.read<MapBloc>().add(LogoutPressed());
context.read<MapBloc>().add(LogoutPressed()), context.go('/login');
}, },
), ),
], ],

View File

@@ -1,3 +1,10 @@
import '../../domain/entities/payment_card.dart';
sealed class PaymentMethodSheetEvent {} sealed class PaymentMethodSheetEvent {}
class PaymentMethodSheetStarted extends PaymentMethodSheetEvent {} class PaymentMethodSheetStarted extends PaymentMethodSheetEvent {}
class SelectPaymentCardRequested extends PaymentMethodSheetEvent {
final PaymentCard card;
SelectPaymentCardRequested(this.card);
}

View File

@@ -2,6 +2,8 @@ sealed class PaymentMethodsEvent {}
class PaymentMethodsStarted extends PaymentMethodsEvent {} class PaymentMethodsStarted extends PaymentMethodsEvent {}
class AddPayemntCardPressed extends PaymentMethodsEvent {}
class PaymentMethodsDeleteCard extends PaymentMethodsEvent { class PaymentMethodsDeleteCard extends PaymentMethodsEvent {
final int cardId; final int cardId;

View File

@@ -1,3 +1,4 @@
import '../../domain/entities/payment_card.dart';
import '../../domain/entities/subscription_period.dart'; import '../../domain/entities/subscription_period.dart';
abstract class SubscriptionDetailsEvent {} abstract class SubscriptionDetailsEvent {}
@@ -18,3 +19,9 @@ class ToggleAgreementEvent extends SubscriptionDetailsEvent {
} }
class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {} class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {}
class SelectCardEvent extends SubscriptionDetailsEvent {
final PaymentCard card;
SelectCardEvent(this.card);
}

View File

@@ -21,3 +21,5 @@ class ToggleAgreement extends TopUpEvent {
final bool value; final bool value;
ToggleAgreement(this.value); ToggleAgreement(this.value);
} }
class TopUpButtonPressed extends TopUpEvent {}

View File

@@ -13,6 +13,11 @@ class CodeChanged extends VerifyCodeEvent {
CodeChanged(this.code); CodeChanged(this.code);
} }
class TimerTicked extends VerifyCodeEvent {
final int secondsLeft;
TimerTicked(this.secondsLeft);
}
class ResendCodePressed extends VerifyCodeEvent {} class ResendCodePressed extends VerifyCodeEvent {}
class VerifyCodeSubmitted extends VerifyCodeEvent {} class VerifyCodeSubmitted extends VerifyCodeEvent {}

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:be_happy/presentation/screens/card_binding_result_screen.dart';
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.dart';
import 'package:be_happy/core/app_colors.dart'; import 'package:be_happy/core/app_colors.dart';
import 'package:be_happy/di/service_locator.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/entities/scooter.dart';
import '../../domain/usecase/activate_subscription_usecase.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/book_scooter_usecase.dart';
import '../../domain/usecase/create_pin_usecase.dart'; import '../../domain/usecase/create_pin_usecase.dart';
import '../../domain/usecase/get_address_by_point_usecase.dart'; import '../../domain/usecase/get_address_by_point_usecase.dart';
@@ -139,7 +141,8 @@ class AppRouter {
createPinUseCase: getIt<CreatePinUseCase>(), createPinUseCase: getIt<CreatePinUseCase>(),
verifyPinUsecase: getIt<VerifyPinUseCase>(), verifyPinUsecase: getIt<VerifyPinUseCase>(),
isPinSetUsecase: getIt<IsPinSetUsecase>(), isPinSetUsecase: getIt<IsPinSetUsecase>(),
)..add(PinScreenStarted()), )
..add(PinScreenStarted()),
child: const PinLoginScreen(), child: const PinLoginScreen(),
) )
), ),
@@ -151,6 +154,26 @@ class AppRouter {
path: '/license-agreement', path: '/license-agreement',
builder: (context, state) => const LicenseAgreementScreen(), 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( GoRoute(
path: '/home', path: '/home',
builder: (context, state) => const MapScreen(), builder: (context, state) => const MapScreen(),
@@ -237,7 +260,8 @@ class AppRouter {
type: MaterialType.transparency, type: MaterialType.transparency,
child: BlocProvider( child: BlocProvider(
create: (context) => create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()), PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>()),
child: PaymentMethodSheet(), child: PaymentMethodSheet(),
), ),
), ),
@@ -298,11 +322,13 @@ class AppRouter {
), ),
GoRoute( GoRoute(
path: 'scooter/:id', path: 'scooter/:id',
builder: (context, state) => builder: (context, state) {
BlocProvider( String address = state.extra as String;
return BlocProvider(
create: (context) => getIt<ScooterDetailBloc>(), create: (context) => getIt<ScooterDetailBloc>(),
child: ScooterDetailScreen(), child: ScooterDetailScreen(address: address),
), );
}
), ),
GoRoute( GoRoute(
path: 'order-photos/:orderId', path: 'order-photos/:orderId',
@@ -371,6 +397,7 @@ class AppRouter {
create: (context) => create: (context) =>
SubscriptionDetailsBloc( SubscriptionDetailsBloc(
getIt<GetSubscriptionByIdUsecase>(), getIt<GetSubscriptionByIdUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<ActivateSubscriptionUsecase>(), getIt<ActivateSubscriptionUsecase>(),
getIt<GetClientSubscriptionsUsecase>(), getIt<GetClientSubscriptionsUsecase>(),
) )
@@ -403,10 +430,12 @@ class AppRouter {
), ),
GoRoute( GoRoute(
path: 'qr-input', path: 'qr-input',
builder: (context, state) => BlocProvider( builder: (context, state) =>
BlocProvider(
create: (context) => create: (context) =>
ScooterCodeBloc( ScooterCodeBloc(
getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>(), getScooterByTitleUsecase: getIt<
GetScooterByTitleUsecase>(),
), ),
child: ScooterCodeInputScreen(), child: ScooterCodeInputScreen(),
), ),
@@ -423,6 +452,7 @@ class AppRouter {
getIt<RemovePaymentCardUsecase>(), getIt<RemovePaymentCardUsecase>(),
getIt<SetMainPaymentCardUsecase>(), getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(), getIt<GetProfileUseCase>(),
getIt<AddPaymentCardUsecase>(),
) )
..add(PaymentMethodsStarted()), ..add(PaymentMethodsStarted()),
child: PaymentMethodsScreen(), child: PaymentMethodsScreen(),
@@ -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()], observers: [BotToastNavigatorObserver()],
redirect: (BuildContext context, GoRouterState state) { redirect: (BuildContext context, GoRouterState state) {

View File

@@ -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<CardBindingResultScreen> createState() => _CardBindingResultScreenState();
}
class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
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,
),
),
),
),
],
],
),
),
],
),
),
),
);
}
}

View File

@@ -5,6 +5,7 @@ import '../../core/app_colors.dart';
import '../../di/service_locator.dart'; import '../../di/service_locator.dart';
import '../../domain/entities/payment_card.dart'; import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/get_payment_cards_usecase.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/custom_app_bar.dart';
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
import '../components/payment_option.dart'; import '../components/payment_option.dart';
@@ -212,10 +213,10 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
(state.useBalance || state.selectedCard != null) (state.useBalance || state.selectedCard != null)
? Padding(padding: const EdgeInsets.symmetric(horizontal: 20), ? Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
child: PaymentOption( child: PaymentOption(
title: state.useBalance ? 'Баланс' : state.selectedCard!.type, title: state.useBalance ? 'Баланс' : "",
subtitle: state.useBalance subtitle: state.useBalance
? '${state.userBalance.toStringAsFixed(2)} BYN' ? '${state.userBalance.toStringAsFixed(2)} BYN'
: '****${state.selectedCard!.cardLastNumber}', : '${state.selectedCard!.cardLastNumber}',
isSelected: true, isSelected: true,
onTap: () async { onTap: () async {
final result = await showModalBottomSheet<dynamic>( final result = await showModalBottomSheet<dynamic>(
@@ -225,6 +226,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
builder: (innerContext) => BlocProvider( builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc( create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(), getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()), )..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet( child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard, initialSelectedCard: state.useBalance ? null : state.selectedCard,

View File

@@ -184,11 +184,14 @@ class PaymentMethodsScreen extends StatelessWidget {
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () async { onTap: () async {
final isCardAdded = await context.push<bool>('/home/payment-methods/add-card'); context.read<PaymentMethodsBloc>().add(AddPayemntCardPressed());
/*final isCardAdded = await context.push<bool>('/home/payment-methods/add-card');
if (isCardAdded == true && context.mounted) { if (isCardAdded == true && context.mounted) {
context.read<PaymentMethodsBloc>().add(PaymentMethodsStarted()); context.read<PaymentMethodsBloc>().add(PaymentMethodsStarted());
} }*/
}, },
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: const Padding( child: const Padding(
@@ -264,12 +267,7 @@ class _CardItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Image.asset( Icon(Icons.credit_card, color: Color(0xFFFFFFFF), size: 40),
_getDefaultIconPath(card.type),
width: 40,
height: 40,
fit: BoxFit.contain,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
@@ -279,7 +277,7 @@ class _CardItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'${card.type} ****${card.cardLastNumber}', 'Карта ${card.cardLastNumber}',
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@@ -79,17 +79,18 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
if (state.isSuccess) { if (state.isSuccess) {
context.go("/pin"); context.go("/pin");
} else if (state.error != null) { } else if (state.error != null) {
setState(() { setState(() { _isFirstTry = false; });
_isFirstTry = false;
});
codeController.clear(); codeController.clear();
FocusScope.of(context).requestFocus(codeFocusNode); FocusScope.of(context).requestFocus(codeFocusNode);
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
context,
).showSnackBar(SnackBar(content: Text(state.error!)));
} else if (state.isBlocked) { } else if (state.isBlocked) {
context.go("/block"); context.go("/block");
} }
if (state.secondsLeft == 0 && codeFocusNode.hasFocus) {
codeFocusNode.unfocus();
codeController.clear();
}
}, },
builder: (context, state) { builder: (context, state) {
return Scaffold( return Scaffold(
@@ -126,12 +127,21 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
), ),
const SizedBox(height: 35), const SizedBox(height: 35),
GestureDetector( GestureDetector(
onTap: openKeyboard, onTap: state.secondsLeft > 0 ? openKeyboard : null,
child: CodeDots(code: state.code, length: 6), child: CodeDots(code: state.code, length: 6),
), ),
const SizedBox(height: 8), 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( Text(
"Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}", "Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}",
style: const TextStyle( style: const TextStyle(
@@ -145,14 +155,14 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
text: state.secondsLeft == 0 text: state.secondsLeft == 0
? "Отправить код повторно" ? "Отправить код повторно"
: "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.", : "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.",
onTap: state.secondsLeft == 0 onTap: (state.secondsLeft == 0 && !state.isSubmitting)
? () { ? () {
context.read<VerifyCodeBloc>().add( context.read<VerifyCodeBloc>().add(
ResendCodePressed(), ResendCodePressed(),
); );
} }
: () {}, : null,
enabled: state.secondsLeft == 0, enabled: state.secondsLeft == 0 && !state.isSubmitting,
width: 250, width: 250,
), ),
const Spacer(), const Spacer(),
@@ -167,6 +177,7 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 6, maxLength: 6,
autofocus: true, autofocus: true,
enabled: state.secondsLeft > 0,
), ),
), ),
), ),

View File

@@ -12,7 +12,8 @@ import '../state/scooter_detail_state.dart';
import '../viewmodel/scooter_detail_bloc.dart'; import '../viewmodel/scooter_detail_bloc.dart';
class ScooterDetailScreen extends StatelessWidget { class ScooterDetailScreen extends StatelessWidget {
const ScooterDetailScreen({super.key}); final String address;
const ScooterDetailScreen({super.key, required this.address});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -83,7 +84,7 @@ class ScooterDetailScreen extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Expanded(child: ScooterInfoSection()), Expanded(child: ScooterInfoSection(distance: scooter?.distance ?? 0, location: address)),
const SizedBox(width: 20), const SizedBox(width: 20),
], ],
), ),

View File

@@ -3,13 +3,21 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/app_colors.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/app_checkbox.dart';
import '../components/custom_app_bar.dart'; import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
import '../components/payment_option.dart';
import '../components/period_selector.dart'; import '../components/period_selector.dart';
import '../components/content_parser.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 '../event/subscription_details_event.dart';
import '../state/susbcription_details_state.dart'; import '../state/susbcription_details_state.dart';
import '../viewmodel/payment_method_sheet_bloc.dart';
import '../viewmodel/susbcription_details_bloc.dart'; import '../viewmodel/susbcription_details_bloc.dart';
class SubscriptionDetailsScreen extends StatelessWidget { class SubscriptionDetailsScreen extends StatelessWidget {
@@ -50,11 +58,9 @@ class SubscriptionDetailsScreen extends StatelessWidget {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 🔹 Контент
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
// Волна снизу
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
@@ -108,10 +114,9 @@ class SubscriptionDetailsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🔹 Обёртка для растягивания контента на всю ширину
Column( Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // ✅ Растягивает детей на всю ширину crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, // ✅ Не занимает лишнее место по вертикали mainAxisSize: MainAxisSize.min,
children: ContentParser.parseContent( children: ContentParser.parseContent(
textJson: state.subscription.fullDescriptionJson, textJson: state.subscription.fullDescriptionJson,
text: state.subscription.fullDescription, text: state.subscription.fullDescription,
@@ -121,6 +126,14 @@ class SubscriptionDetailsScreen extends StatelessWidget {
if (isAvailableForPurchase) ...[ if (isAvailableForPurchase) ...[
const SizedBox(height: 30), const SizedBox(height: 30),
_ActionCard(state: state), _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), const SizedBox(height: 30),
GradientButton( GradientButton(
text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать', text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать',
@@ -129,7 +142,7 @@ class SubscriptionDetailsScreen extends StatelessWidget {
ActivateSubscriptionPressed(), ActivateSubscriptionPressed(),
); );
}, },
enabled: state.isAgreed, enabled: state.isAgreed && state.selectedCard != null,
width: double.infinity, width: double.infinity,
height: 56, height: 56,
fontSize: 16, 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<PaymentCard>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(showBalance: false),
),
);
if (selectedCard != null && context.mounted) {
context.read<SubscriptionDetailsBloc>().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 { class _ActionCard extends StatelessWidget {
final DetailsContentState state; final DetailsContentState state;
@@ -216,6 +298,7 @@ class _ActionCard extends StatelessWidget {
), ),
); );
} }
} }
class _PriceRow extends StatelessWidget { class _PriceRow extends StatelessWidget {

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:path/path.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../di/service_locator.dart'; import '../../di/service_locator.dart';
import '../../domain/entities/payment_card.dart'; import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/get_payment_cards_usecase.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/app_checkbox.dart';
import '../components/custom_app_bar.dart'; // ✅ Уже есть import '../components/custom_app_bar.dart'; // ✅ Уже есть
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
@@ -57,7 +59,9 @@ class TopUpScreen extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
_buildAgreement(state, context), _buildAgreement(state, context),
const Spacer(), const Spacer(),
_buildPayButton(state), _buildPayButton(state, () {
context.read<TopUpBloc>().add(TopUpButtonPressed());
}),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
), ),
@@ -158,17 +162,21 @@ class TopUpScreen extends StatelessWidget {
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: PaymentOption( child: PaymentOption(
title: state.selectedCard!.type, title: "Карта",
subtitle: '****${state.selectedCard!.cardLastNumber}', subtitle: '${state.selectedCard!.cardLastNumber}',
isSelected: true, isSelected: true,
onTap: () async { onTap: () async {
final selectedCard = await showModalBottomSheet<PaymentCard>( final selectedCard = await showModalBottomSheet<PaymentCard>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider( builder: (innerContext) =>
BlocProvider(
create: (context) => create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()) PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)
..add(PaymentMethodSheetStarted()), ..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(showBalance: false,), child: const PaymentMethodSheet(showBalance: false,),
), ),
@@ -260,10 +268,10 @@ class TopUpScreen extends StatelessWidget {
); );
} }
Widget _buildPayButton(TopUpState state) { Widget _buildPayButton(TopUpState state, VoidCallback onTap) {
return GradientButton( return GradientButton(
text: 'Оплатить', text: 'Оплатить',
onTap: state.isAgreed ? () {} : null, onTap: state.isAgreed ? onTap : null,
enabled: state.isAgreed, enabled: state.isAgreed,
showArrows: true, showArrows: true,
height: 56, height: 56,

View File

@@ -1,3 +1,4 @@
import 'package:be_happy/domain/entities/payment_card.dart';
import 'package:be_happy/domain/entities/subscription.dart'; import 'package:be_happy/domain/entities/subscription.dart';
import '../../domain/entities/subscription_period.dart'; import '../../domain/entities/subscription_period.dart';
@@ -8,19 +9,24 @@ class DetailsLoading extends SubscriptionDetailsState {}
class DetailsError extends SubscriptionDetailsState { class DetailsError extends SubscriptionDetailsState {
final String message; final String message;
DetailsError(this.message); DetailsError(this.message);
} }
class DetailsContentState extends SubscriptionDetailsState { class DetailsContentState extends SubscriptionDetailsState {
final Subscription subscription; final Subscription subscription;
final SubscriptionPeriod selectedPeriod; final SubscriptionPeriod selectedPeriod;
final List<PaymentCard> cards;
final PaymentCard selectedCard;
final bool isAgreed; final bool isAgreed;
final bool isAlreadyPurchased; // ✅ Куплена ли эта подписка сейчас final bool isAlreadyPurchased;
final bool isSuccess; // ✅ Сигнал для навигатора назад final bool isSuccess;
DetailsContentState({ DetailsContentState({
required this.subscription, required this.subscription,
required this.selectedPeriod, required this.selectedPeriod,
required this.selectedCard,
this.cards = const [],
this.isAgreed = false, this.isAgreed = false,
this.isAlreadyPurchased = false, this.isAlreadyPurchased = false,
this.isSuccess = false, this.isSuccess = false,
@@ -28,6 +34,7 @@ class DetailsContentState extends SubscriptionDetailsState {
DetailsContentState copyWith({ DetailsContentState copyWith({
SubscriptionPeriod? selectedPeriod, SubscriptionPeriod? selectedPeriod,
PaymentCard? selectedCard,
bool? isAgreed, bool? isAgreed,
bool? isAlreadyPurchased, bool? isAlreadyPurchased,
bool? isSuccess, bool? isSuccess,
@@ -35,6 +42,7 @@ class DetailsContentState extends SubscriptionDetailsState {
return DetailsContentState( return DetailsContentState(
subscription: this.subscription, subscription: this.subscription,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
selectedCard: selectedCard ?? this.selectedCard,
isAgreed: isAgreed ?? this.isAgreed, isAgreed: isAgreed ?? this.isAgreed,
isAlreadyPurchased: isAlreadyPurchased ?? this.isAlreadyPurchased, isAlreadyPurchased: isAlreadyPurchased ?? this.isAlreadyPurchased,
isSuccess: isSuccess ?? this.isSuccess, isSuccess: isSuccess ?? this.isSuccess,

View File

@@ -52,17 +52,17 @@ class AddCardBloc extends Bloc<AddCardEvent, AddCardState> {
} }
final result = await addPaymentCardUsecase( final result = await addPaymentCardUsecase(
cardNumber: state.cardNumber.replaceAll(' ', ''), /*cardNumber: state.cardNumber.replaceAll(' ', ''),
expiryMonth: expiryParts[0], expiryMonth: expiryParts[0],
expiryYear: expiryParts[1], expiryYear: expiryParts[1],
cardHolder: state.cardHolder.trim(), cardHolder: state.cardHolder.trim(),
cvv: state.cvv, cvv: state.cvv,*/
); );
if (result is Success) { if (result is Success) {
emit(state.copyWith(status: AddCardStatus.success)); emit(state.copyWith(status: AddCardStatus.success));
} else if (result is Failure) { } else if (result is Failure) {
final failure = result.failure; /*final failure = result.failure;
String errorMessage = 'Ошибка добавления карты'; String errorMessage = 'Ошибка добавления карты';
@@ -72,12 +72,12 @@ class AddCardBloc extends Bloc<AddCardEvent, AddCardState> {
errorMessage = 'Доступ заблокирован'; errorMessage = 'Доступ заблокирован';
} else if (failure is UnknownFailure) { } else if (failure is UnknownFailure) {
errorMessage = failure.message ?? 'Неизвестная ошибка'; errorMessage = failure.message ?? 'Неизвестная ошибка';
} }*/
emit(state.copyWith( /* emit(state.copyWith(
status: AddCardStatus.failure, status: AddCardStatus.failure,
errorMessage: errorMessage, errorMessage: errorMessage,
)); ));*/
} }
} }

View File

@@ -264,8 +264,9 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
FutureOr<void> _onLogoutPressed( FutureOr<void> _onLogoutPressed(
LogoutPressed event, LogoutPressed event,
Emitter<ScooterState> emit, Emitter<ScooterState> emit,
) { ) async {
logoutUseCase(); await logoutUseCase();
stopNotificationStream();
splashBloc.add(AuthCheckRequested()); splashBloc.add(AuthCheckRequested());
} }

View File

@@ -3,13 +3,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/result.dart'; import '../../core/result.dart';
import '../../domain/entities/payment_card.dart'; import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/get_payment_cards_usecase.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/payment_method_sheet_event.dart';
import '../state/payment_method_sheet_state.dart'; import '../state/payment_method_sheet_state.dart';
class PaymentMethodSheetBloc extends Bloc<PaymentMethodSheetEvent, PaymentMethodSheetState> { class PaymentMethodSheetBloc extends Bloc<PaymentMethodSheetEvent, PaymentMethodSheetState> {
final GetPaymentCardsUsecase _getPaymentCardsUsecase; final GetPaymentCardsUsecase _getPaymentCardsUsecase;
final SetMainPaymentCardUsecase _setMainPaymentCardUsecase;
PaymentMethodSheetBloc(this._getPaymentCardsUsecase) PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase)
: super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) {
on<PaymentMethodSheetStarted>(_onStarted); on<PaymentMethodSheetStarted>(_onStarted);
} }
@@ -41,4 +43,53 @@ class PaymentMethodSheetBloc extends Bloc<PaymentMethodSheetEvent, PaymentMethod
)); ));
} }
} }
Future<void> _onSelectCard(
SelectPaymentCardRequested event,
Emitter<PaymentMethodSheetState> emit,
) async {
// Включаем локальный лоадер на шторке, либо просто ждем ответа
emit(state.copyWith(status: PaymentMethodSheetStatus.loading));
final result = await _setMainPaymentCardUsecase(event.card.id);
if (result is Success<void>) {
final cardsResult = await _getPaymentCardsUsecase();
if (cardsResult is Success<List<PaymentCard>>) {
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<void> _loadCards(Emitter<PaymentMethodSheetState> emit) async {
try {
final result = await _getPaymentCardsUsecase();
if (result is Success<List<PaymentCard>>) {
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(),
));
}
}
} }

View File

@@ -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:be_happy/domain/usecase/get_profile_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/result.dart'; import '../../core/result.dart';
import '../../core/failures.dart'; import '../../core/failures.dart';
@@ -15,16 +19,19 @@ class PaymentMethodsBloc extends Bloc<PaymentMethodsEvent, PaymentMethodsState>
final RemovePaymentCardUsecase _removePaymentCardUsecase; final RemovePaymentCardUsecase _removePaymentCardUsecase;
final SetMainPaymentCardUsecase _setMainPaymentCardUsecase; final SetMainPaymentCardUsecase _setMainPaymentCardUsecase;
final GetProfileUseCase _getProfileUseCase; final GetProfileUseCase _getProfileUseCase;
final AddPaymentCardUsecase _addPaymentCardUsecase;
PaymentMethodsBloc( PaymentMethodsBloc(
this._getPaymentCardsUsecase, this._getPaymentCardsUsecase,
this._removePaymentCardUsecase, this._removePaymentCardUsecase,
this._setMainPaymentCardUsecase, this._setMainPaymentCardUsecase,
this._getProfileUseCase, this._getProfileUseCase,
this._addPaymentCardUsecase,
) : super(PaymentMethodsState(status: PaymentMethodsStatus.initial)) { ) : super(PaymentMethodsState(status: PaymentMethodsStatus.initial)) {
on<PaymentMethodsStarted>(_onStarted); on<PaymentMethodsStarted>(_onStarted);
on<PaymentMethodsDeleteCard>(_onDeleteCard); on<PaymentMethodsDeleteCard>(_onDeleteCard);
on<PaymentMethodsSetMainCard>(_onSetMainCard); on<PaymentMethodsSetMainCard>(_onSetMainCard);
on<AddPayemntCardPressed>(_onAddPayemntCardPressed);
} }
Future<void> _onStarted( Future<void> _onStarted(
@@ -136,4 +143,54 @@ class PaymentMethodsBloc extends Bloc<PaymentMethodsEvent, PaymentMethodsState>
)); ));
} }
} }
}
FutureOr<void> _onAddPayemntCardPressed(
AddPayemntCardPressed event,
Emitter<PaymentMethodsState> emit
) async {
emit(state.copyWith(status: PaymentMethodsStatus.loading));
try {
final result = await _addPaymentCardUsecase();
if (result is Success<Map<String, dynamic>>) {
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(),
));
}
}}

View File

@@ -1,6 +1,8 @@
import 'package:be_happy/core/result.dart'; 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/entities/subscription.dart';
import 'package:be_happy/domain/usecase/activate_subscription_usecase.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:be_happy/domain/usecase/get_subscription_by_id_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -12,27 +14,35 @@ import '../state/susbcription_details_state.dart';
class SubscriptionDetailsBloc class SubscriptionDetailsBloc
extends Bloc<SubscriptionDetailsEvent, SubscriptionDetailsState> { extends Bloc<SubscriptionDetailsEvent, SubscriptionDetailsState> {
final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase; final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase;
final GetPaymentCardsUsecase getPaymentCardsUsecase;
final ActivateSubscriptionUsecase activateSubscriptionUsecase; final ActivateSubscriptionUsecase activateSubscriptionUsecase;
final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase;
SubscriptionDetailsBloc( SubscriptionDetailsBloc(
this.getSubscriptionByIdUsecase, this.getSubscriptionByIdUsecase,
this.getPaymentCardsUsecase,
this.activateSubscriptionUsecase, this.activateSubscriptionUsecase,
this.getClientSubscriptionsUsecase, this.getClientSubscriptionsUsecase,
) : super(DetailsLoading()) { ) : super(DetailsLoading()) {
on<LoadDetailsEvent>((event, emit) async { on<LoadDetailsEvent>((event, emit) async {
emit(DetailsLoading()); emit(DetailsLoading());
try { try {
final result = await getSubscriptionByIdUsecase(event.subscriptionId); final cardsResult = await getPaymentCardsUsecase();
final subsResult = await getSubscriptionByIdUsecase(
event.subscriptionId,
);
final clientSubsResult = await getClientSubscriptionsUsecase(); final clientSubsResult = await getClientSubscriptionsUsecase();
bool isPurchased = false; bool isPurchased = false;
switch (clientSubsResult) { switch (clientSubsResult) {
case Success<List<ClientSubscription>>(): case Success<List<ClientSubscription>>():
isPurchased = clientSubsResult.data?.any( isPurchased =
clientSubsResult.data?.any(
(element) => element.subscriptionId == event.subscriptionId, (element) => element.subscriptionId == event.subscriptionId,
) ?? false; ) ??
false;
break; break;
case Failure<List<ClientSubscription>>(): case Failure<List<ClientSubscription>>():
@@ -40,16 +50,30 @@ class SubscriptionDetailsBloc
break; break;
} }
switch (result) { switch (subsResult) {
case Success<Subscription>(): case Success<Subscription>():
final sub = result.data; final sub = subsResult.data;
if (sub == null) return; if (sub == null) return;
List<PaymentCard> loadedCards = [];
PaymentCard? defaultCard;
if (cardsResult is Success<List<PaymentCard>>) {
loadedCards = cardsResult.data ?? [];
if (loadedCards.isNotEmpty) {
defaultCard = loadedCards.firstWhere(
(element) => element.isMain,
);
}
}
emit( emit(
DetailsContentState( DetailsContentState(
subscription: sub, subscription: sub,
selectedPeriod: sub.options.first, selectedPeriod: sub.options.first,
isAlreadyPurchased: isPurchased, isAlreadyPurchased: isPurchased,
cards: loadedCards,
selectedCard: defaultCard ?? loadedCards.first,
), ),
); );
break; break;
@@ -70,6 +94,12 @@ class SubscriptionDetailsBloc
} }
}); });
on<SelectCardEvent>((event, emit) {
if (state is DetailsContentState) {
emit((state as DetailsContentState).copyWith(selectedCard: event.card));
}
});
on<ToggleAgreementEvent>((event, emit) { on<ToggleAgreementEvent>((event, emit) {
if (state is DetailsContentState) { if (state is DetailsContentState) {
emit((state as DetailsContentState).copyWith(isAgreed: event.value)); emit((state as DetailsContentState).copyWith(isAgreed: event.value));
@@ -79,16 +109,18 @@ class SubscriptionDetailsBloc
on<ActivateSubscriptionPressed>((event, emit) async { on<ActivateSubscriptionPressed>((event, emit) async {
switch (state) { switch (state) {
case DetailsContentState contentState: case DetailsContentState contentState:
if (!contentState.isAgreed) { if (!contentState.isAgreed || contentState.selectedCard == null) {
return; return;
} }
final result = await activateSubscriptionUsecase(contentState.selectedPeriod.id); final result = await activateSubscriptionUsecase(
contentState.selectedPeriod.id,
contentState.selectedCard.id,
);
if (result is Success) { if (result is Success) {
emit(contentState.copyWith(isSuccess: true)); emit(contentState.copyWith(isSuccess: true));
} else { } else {}
}
break; break;
default: default:
break; break;

View File

@@ -22,7 +22,6 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
on<LoadTopUpData>((event, emit) async { on<LoadTopUpData>((event, emit) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
// Запускаем оба запроса параллельно
final results = await Future.wait([ final results = await Future.wait([
getCertificatesUsecase(), getCertificatesUsecase(),
getUserCards(), getUserCards(),
@@ -77,5 +76,21 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
on<ToggleAgreement>( on<ToggleAgreement>(
(event, emit) => emit(state.copyWith(isAgreed: event.value)), (event, emit) => emit(state.copyWith(isAgreed: event.value)),
); );
on<TopUpButtonPressed>((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));
}
}
});
} }
} }

View File

@@ -8,12 +8,26 @@ import '../../domain/usecase/verify_code_usecase.dart';
import '../event/verify_code_event.dart'; import '../event/verify_code_event.dart';
import '../state/verify_code_state.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<VerifyCodeEvent, VerifyCodeState> { class VerifyCodeBloc extends Bloc<VerifyCodeEvent, VerifyCodeState> {
final VerifyCodeUseCase _verifyCodeUseCase; final VerifyCodeUseCase _verifyCodeUseCase;
final LoginUseCase _loginUseCase; // 🔹 Добавляем поле для юзкейса логина
Timer? _timer; Timer? _timer;
VerifyCodeBloc(this._verifyCodeUseCase) // 🔹 Обновляем конструктор
VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase)
: super(VerifyCodeState.initial()) { : super(VerifyCodeState.initial()) {
on<VerifyCodeStarted>((event, emit) { on<VerifyCodeStarted>((event, emit) {
emit(state.copyWith( emit(state.copyWith(
phoneNumber: event.phoneNumber, phoneNumber: event.phoneNumber,
@@ -24,14 +38,40 @@ class VerifyCodeBloc extends Bloc<VerifyCodeEvent, VerifyCodeState> {
}); });
on<CodeChanged>((event, emit) { on<CodeChanged>((event, emit) {
emit(state.copyWith(code: event.code)); emit(state.copyWith(
code: event.code,
error: null,
));
}); });
on<ResendCodePressed>((event, emit) { on<ResendCodePressed>((event, emit) async {
if (state.secondsLeft == 0) { if (state.secondsLeft == 0 && !state.isSubmitting) {
emit(state.copyWith(secondsLeft: 60)); 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(); _startTimer();
} catch (e) {
emit(state.copyWith(
isSubmitting: false,
error: 'Не удалось повторно отправить код. Попробуйте позже.',
));
} }
}
});
on<TimerTicked>((event, emit) {
emit(state.copyWith(secondsLeft: event.secondsLeft));
}); });
on<VerifyCodeSubmitted>(_onVerifyCodeSubmitted); on<VerifyCodeSubmitted>(_onVerifyCodeSubmitted);
@@ -39,41 +79,48 @@ class VerifyCodeBloc extends Bloc<VerifyCodeEvent, VerifyCodeState> {
void _startTimer() { void _startTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (state.secondsLeft <= 1) { final currentSeconds = state.secondsLeft;
if (currentSeconds <= 1) {
timer.cancel(); timer.cancel();
emit(state.copyWith(secondsLeft: 0)); add(TimerTicked(0));
} else { } else {
emit(state.copyWith(secondsLeft: state.secondsLeft - 1)); add(TimerTicked(currentSeconds - 1));
} }
}); });
} }
Future<void> _onVerifyCodeSubmitted(VerifyCodeSubmitted event, Future<void> _onVerifyCodeSubmitted(VerifyCodeSubmitted event,
Emitter<VerifyCodeState> emit,) async { Emitter<VerifyCodeState> 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)); emit(state.copyWith(isSubmitting: true, error: null));
// 🔹 Здесь автоматически подставится обновленный из стейта tempToken
final result = await _verifyCodeUseCase.execute(state.code, state.tempToken); final result = await _verifyCodeUseCase.execute(state.code, state.tempToken);
final newState = switch (result) { final newState = switch (result) {
Success() => state.copyWith( Success() => (() {
_timer?.cancel();
return state.copyWith(
isSubmitting: false, isSubmitting: false,
isSuccess: true, isSuccess: true,
), );
})(),
Failure(failure: final f) => switch (f) { Failure(failure: final f) => switch (f) {
AuthFailure(attemptsLeft: final count) => state.copyWith( AuthFailure(attemptsLeft: final count) => state.copyWith(
isSubmitting: false, isSubmitting: false,
attemptsLeft: count, attemptsLeft: count,
error: 'Неверный код. Осталось попыток: $count', error: 'Неверный код. Осталось попыток: $count',
), ),
AuthBlockFailure() => state.copyWith( AuthBlockFailure() => (() {
_timer?.cancel();
return state.copyWith(
isSubmitting: false, isSubmitting: false,
isBlocked: true, isBlocked: true,
error: 'Вы заблокированы за слишком частые попытки', error: 'Вы заблокированы за слишком частые попытки',
), );
})(),
_ => state.copyWith( _ => state.copyWith(
isSubmitting: false, isSubmitting: false,
error: 'Произошла ошибка. Попробуйте позже', error: 'Произошла ошибка. Попробуйте позже',

View File

@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -524,26 +524,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -572,26 +572,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -817,10 +817,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -905,10 +905,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -974,5 +974,5 @@ packages:
source: hosted source: hosted
version: "4.2.1" version: "4.2.1"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.32.0" flutter: ">=3.32.0"

View File

@@ -2,7 +2,7 @@ name: be_happy
description: "" description: ""
publish_to: 'none' publish_to: 'none'
version: 1.0.0+12 version: 1.0.0+13
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1