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

@@ -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<String, dynamic> 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,
);
}
}

View File

@@ -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<bool> activateSubscription({required int optionId}) async {
Future<bool> 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<void> addPaymentCard({
required String cardNumber,
required String cardHolder,
required int expirationMonth,
required int expirationYear,
required String cvv,
}) async {
Future<Map<String, dynamic>> 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);

View File

@@ -32,25 +32,25 @@ class PaymentRepositoryImpl implements PaymentRepository {
}
@override
Future<Result<void>> addPaymentCard({
required String cardNumber,
Future<Result<Map<String, dynamic>>> 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<Result<bool>> activateSubscription(int optionId) async {
Future<Result<bool>> 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));

View File

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

View File

@@ -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,
);
}
}

View File

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

View File

@@ -6,19 +6,19 @@ class AddPaymentCardUsecase {
AddPaymentCardUsecase(this.repository);
Future<Result<void>> call({
required String cardNumber,
Future<Result<Map<String, dynamic>>> 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,*/
);
}
}

View File

@@ -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 минута'),
],

View File

@@ -128,6 +128,7 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
child: _ScooterCard(
scooter: scooter,
isActive: isActive,
address: state.address ?? "Unknown address",
),
),
),
@@ -151,8 +152,9 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
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);
},
),
),

View File

@@ -192,13 +192,18 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
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<PaymentMethodSheetBloc>().add(
SelectPaymentCardRequested(card),
);
Navigator.pop(context, card);
},
),

View File

@@ -151,6 +151,7 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
child: _ScooterCard(
scooter: scooter,
isActive: isActive,
address: state.address ?? "Unknown address",
),
),
),
@@ -174,9 +175,10 @@ class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
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]);
},
),
],

View File

@@ -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<TariffSheet> {
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<dynamic>(
@@ -301,6 +302,7 @@ class _TariffSheetState extends State<TariffSheet> {
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard,
@@ -381,7 +383,6 @@ class _TariffSheetState extends State<TariffSheet> {
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,

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -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 {}
class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {}
class SelectCardEvent extends SubscriptionDetailsEvent {
final PaymentCard card;
SelectCardEvent(this.card);
}

View File

@@ -20,4 +20,6 @@ class SelectCard extends TopUpEvent {
class ToggleAgreement extends TopUpEvent {
final bool value;
ToggleAgreement(this.value);
}
}
class TopUpButtonPressed extends TopUpEvent {}

View File

@@ -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 {}

View File

@@ -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<CreatePinUseCase>(),
verifyPinUsecase: getIt<VerifyPinUseCase>(),
isPinSetUsecase: getIt<IsPinSetUsecase>(),
)..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<GetPaymentCardsUsecase>()),
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>()),
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<ScooterDetailBloc>(),
child: ScooterDetailScreen(),
),
child: ScooterDetailScreen(address: address),
);
}
),
GoRoute(
path: 'order-photos/:orderId',
@@ -371,6 +397,7 @@ class AppRouter {
create: (context) =>
SubscriptionDetailsBloc(
getIt<GetSubscriptionByIdUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<ActivateSubscriptionUsecase>(),
getIt<GetClientSubscriptionsUsecase>(),
)
@@ -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<GetScooterByTitleUsecase>(),
),
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<RemovePaymentCardUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
getIt<AddPaymentCardUsecase>(),
)
..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) {

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 '../../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<dynamic>(
@@ -225,6 +226,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard,

View File

@@ -184,11 +184,14 @@ class PaymentMethodsScreen extends StatelessWidget {
color: Colors.transparent,
child: InkWell(
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) {
context.read<PaymentMethodsBloc>().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),

View File

@@ -79,17 +79,18 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
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<PhoneLoginScreen> {
),
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<PhoneLoginScreen> {
text: state.secondsLeft == 0
? "Отправить код повторно"
: "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.",
onTap: state.secondsLeft == 0
onTap: (state.secondsLeft == 0 && !state.isSubmitting)
? () {
context.read<VerifyCodeBloc>().add(
ResendCodePressed(),
);
}
: () {},
enabled: state.secondsLeft == 0,
context.read<VerifyCodeBloc>().add(
ResendCodePressed(),
);
}
: null,
enabled: state.secondsLeft == 0 && !state.isSubmitting,
width: 250,
),
const Spacer(),
@@ -167,6 +177,7 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
keyboardType: TextInputType.number,
maxLength: 6,
autofocus: true,
enabled: state.secondsLeft > 0,
),
),
),

View File

@@ -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),
],
),

View File

@@ -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<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 {
final DetailsContentState state;
@@ -216,6 +298,7 @@ class _ActionCard extends StatelessWidget {
),
);
}
}
class _PriceRow extends StatelessWidget {

View File

@@ -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<TopUpBloc, TopUpState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
BlocBuilder<TopUpBloc, TopUpState>(
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<TopUpBloc>().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<PaymentCard>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider(
create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>())
..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(showBalance: false,),
),
builder: (innerContext) =>
BlocProvider(
create: (context) =>
PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
)
..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,

View File

@@ -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<PaymentCard> 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,
);
}
}
}

View File

@@ -52,17 +52,17 @@ class AddCardBloc extends Bloc<AddCardEvent, AddCardState> {
}
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<AddCardEvent, AddCardState> {
errorMessage = 'Доступ заблокирован';
} else if (failure is UnknownFailure) {
errorMessage = failure.message ?? 'Неизвестная ошибка';
}
}*/
emit(state.copyWith(
/* emit(state.copyWith(
status: AddCardStatus.failure,
errorMessage: errorMessage,
));
));*/
}
}

View File

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

View File

@@ -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<PaymentMethodSheetEvent, PaymentMethodSheetState> {
final GetPaymentCardsUsecase _getPaymentCardsUsecase;
final SetMainPaymentCardUsecase _setMainPaymentCardUsecase;
PaymentMethodSheetBloc(this._getPaymentCardsUsecase)
PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase)
: super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) {
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: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<PaymentMethodsEvent, PaymentMethodsState>
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<PaymentMethodsStarted>(_onStarted);
on<PaymentMethodsDeleteCard>(_onDeleteCard);
on<PaymentMethodsSetMainCard>(_onSetMainCard);
on<AddPayemntCardPressed>(_onAddPayemntCardPressed);
}
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/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<SubscriptionDetailsEvent, SubscriptionDetailsState> {
final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase;
final GetPaymentCardsUsecase getPaymentCardsUsecase;
final ActivateSubscriptionUsecase activateSubscriptionUsecase;
final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase;
SubscriptionDetailsBloc(
this.getSubscriptionByIdUsecase,
this.getPaymentCardsUsecase,
this.activateSubscriptionUsecase,
this.getClientSubscriptionsUsecase,
) : super(DetailsLoading()) {
on<LoadDetailsEvent>((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<List<ClientSubscription>>():
isPurchased = clientSubsResult.data?.any(
isPurchased =
clientSubsResult.data?.any(
(element) => element.subscriptionId == event.subscriptionId,
) ?? false;
) ??
false;
break;
case Failure<List<ClientSubscription>>():
@@ -40,16 +50,30 @@ class SubscriptionDetailsBloc
break;
}
switch (result) {
switch (subsResult) {
case Success<Subscription>():
final sub = result.data;
final sub = subsResult.data;
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(
DetailsContentState(
subscription: sub,
selectedPeriod: sub.options.first,
isAlreadyPurchased: isPurchased,
cards: loadedCards,
selectedCard: defaultCard ?? loadedCards.first,
),
);
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) {
if (state is DetailsContentState) {
emit((state as DetailsContentState).copyWith(isAgreed: event.value));
@@ -79,16 +109,18 @@ class SubscriptionDetailsBloc
on<ActivateSubscriptionPressed>((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;

View File

@@ -22,7 +22,6 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
on<LoadTopUpData>((event, emit) async {
emit(state.copyWith(isLoading: true));
// Запускаем оба запроса параллельно
final results = await Future.wait([
getCertificatesUsecase(),
getUserCards(),
@@ -77,5 +76,21 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
on<ToggleAgreement>(
(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 '../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> {
final VerifyCodeUseCase _verifyCodeUseCase;
final LoginUseCase _loginUseCase; // 🔹 Добавляем поле для юзкейса логина
Timer? _timer;
VerifyCodeBloc(this._verifyCodeUseCase)
// 🔹 Обновляем конструктор
VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase)
: super(VerifyCodeState.initial()) {
on<VerifyCodeStarted>((event, emit) {
emit(state.copyWith(
phoneNumber: event.phoneNumber,
@@ -24,56 +38,89 @@ class VerifyCodeBloc extends Bloc<VerifyCodeEvent, VerifyCodeState> {
});
on<CodeChanged>((event, emit) {
emit(state.copyWith(code: event.code));
emit(state.copyWith(
code: event.code,
error: null,
));
});
on<ResendCodePressed>((event, emit) {
if (state.secondsLeft == 0) {
emit(state.copyWith(secondsLeft: 60));
_startTimer();
on<ResendCodePressed>((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<TimerTicked>((event, emit) {
emit(state.copyWith(secondsLeft: event.secondsLeft));
});
on<VerifyCodeSubmitted>(_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<void> _onVerifyCodeSubmitted(VerifyCodeSubmitted event,
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));
// 🔹 Здесь автоматически подставится обновленный из стейта 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<VerifyCodeEvent, VerifyCodeState> {
_timer?.cancel();
return super.close();
}
}
}