3 Commits

Author SHA1 Message Date
902c6d27a4 Merge pull request 'fix/bug-fix' (#4) from fix/bug-fix into main
Reviewed-on: #4
2026-07-12 22:53:19 +00:00
98c5a7a1d1 fix 2026-07-13 01:38:13 +03:00
9eda119527 fix 2026-07-08 13:09:20 +03:00
76 changed files with 1597 additions and 640 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

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

BIN
lib.zip Normal file

Binary file not shown.

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import '../../domain/entities/client_notification.dart'; import '../../domain/entities/client_notification.dart';
class ClientNotificationDto { class ClientNotificationDto {
@@ -43,12 +42,19 @@ class ClientNotificationDto {
clientId: clientId, clientId: clientId,
type: _parseType(type), type: _parseType(type),
category: _parseCategory(category), category: _parseCategory(category),
createdAt: DateTime.parse(createdAt), createdAt: _parseUtcDate(createdAt),
canceledAt: canceledAt != null ? DateTime.parse(canceledAt!) : null, canceledAt: canceledAt != null ? _parseUtcDate(canceledAt!) : null,
readAt: readAt != null ? DateTime.parse(readAt!) : null, readAt: readAt != null ? _parseUtcDate(readAt!) : null,
); );
} }
DateTime _parseUtcDate(String dateString) {
if (dateString.endsWith('Z') || dateString.contains('+') || dateString.contains('-')) {
return DateTime.parse(dateString).toLocal();
}
return DateTime.parse('${dateString}Z').toLocal();
}
NotificationType _parseType(String type) { NotificationType _parseType(String type) {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case 'info': case 'info':
@@ -80,4 +86,4 @@ class ClientNotificationDto {
return NotificationCategory.companyInfo; return NotificationCategory.companyInfo;
} }
} }
} }

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) {
@@ -143,11 +147,12 @@ class ApiService {
} }
dynamic balanceRaw = profileData["balance"]; dynamic balanceRaw = profileData["balance"];
int? balance; double? balance;
if (balanceRaw is int) {
balance = balanceRaw; if (balanceRaw is num) {
balance = balanceRaw.toDouble();
} else if (balanceRaw is String) { } else if (balanceRaw is String) {
balance = int.tryParse(balanceRaw); balance = double.tryParse(balanceRaw);
} }
return UserProfile( return UserProfile(
@@ -177,13 +182,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'],
); );
}), }),
); );
@@ -238,11 +239,11 @@ class ApiService {
} }
dynamic balanceRaw = data["balance"]; dynamic balanceRaw = data["balance"];
int? balance; double? balance;
if (balanceRaw is int) { if (balanceRaw is double) {
balance = balanceRaw; balance = balanceRaw;
} else if (balanceRaw is String) { } else if (balanceRaw is String) {
balance = int.tryParse(balanceRaw); balance = double.tryParse(balanceRaw);
} }
return UserProfile( return UserProfile(
@@ -420,7 +421,7 @@ class ApiService {
int page = 1, int page = 1,
int pageSize = 500, int pageSize = 500,
}) async { }) async {
const path = "$baseUrl/zone/available"; const path = "$baseUrl/zone/available2";
final queryParams = { final queryParams = {
'readAll': 'true', 'readAll': 'true',
@@ -517,13 +518,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 +540,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);
@@ -714,17 +709,13 @@ class ApiService {
} }
} }
Future<ScooterOrder?> cancelRide(int orderId) async { Future<void> cancelRide(int orderId) async {
try { try {
final response = await _dio.put( final response = await _dio.put(
"$baseUrl/scooterorder/$orderId/cancel", "$baseUrl/scooterorder/$orderId/cancel",
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
}
return null;
} on DioException catch (e) { } on DioException catch (e) {
_handleDioError(e); _handleDioError(e);
return null; return null;
@@ -765,7 +756,7 @@ class ApiService {
} }
} }
Future<ScooterOrder?> finishRide({ Future<void> finishRide({
required int orderId, required int orderId,
required List<int> filesId, required List<int> filesId,
}) async { }) async {
@@ -776,10 +767,6 @@ class ApiService {
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
}
return null;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 400) { if (e.response?.statusCode == 400) {
final data = e.response?.data; final data = e.response?.data;
@@ -1019,10 +1006,10 @@ class ApiService {
if (event.data != null && event.data!.isNotEmpty) { if (event.data != null && event.data!.isNotEmpty) {
try { try {
final Map<String, dynamic> jsonData = jsonDecode(event.data!); final Map<String, dynamic> jsonData = jsonDecode(event.data!);
print("✅ [SSE] Parsed JSON: $jsonData"); print("[SSE] Parsed JSON: $jsonData");
controller.add(jsonData); controller.add(jsonData);
} catch (e) { } catch (e) {
print("❌ [SSE] JSON Parse Error: $e | Raw: ${event.data}"); print("[SSE] JSON Parse Error: $e | Raw: ${event.data}");
} }
} }
}, },
@@ -1040,17 +1027,19 @@ class ApiService {
return controller.stream; return controller.stream;
} }
Future<List<Map<String, dynamic>>> getNotifications() async { Future<Map<String, dynamic>> getNotifications({
final url = Uri.parse('$baseUrl/notification/client'); int page = 1,
int pageSize = 10,
}) async {
final url = Uri.parse('$baseUrl/notification/client').replace(
queryParameters: {
'page': page.toString(),
'pageSize': pageSize.toString(),
},
);
final accessToken = await _securityService.getAccessToken(); final accessToken = await _securityService.getAccessToken();
if (accessToken == null) { if (accessToken == null) throw UnauthorizedException();
print("APISERVICE Error: Access token is null.");
throw UnauthorizedException();
}
print("GET NOTIFICATIONS REQUEST:");
print("URL: $url");
final response = await http.get( final response = await http.get(
url, url,
@@ -1060,34 +1049,17 @@ class ApiService {
}, },
); );
print("GET NOTIFICATIONS RESPONSE:");
print("STATUS: ${response.statusCode}");
print("BODY: ${response.body}");
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)); final data = jsonDecode(utf8.decode(response.bodyBytes));
if (data is Map<String, dynamic>) {
// ✅ Проверяем, является ли ответ массивом или объектом с data[] return data;
if (data is List) {
return data.cast<Map<String, dynamic>>();
} else if (data is Map<String, dynamic>) {
final list = data['data'];
if (list is List) {
return list.cast<Map<String, dynamic>>();
} else {
throw Exception(
'Expected a List under "data" but got ${list.runtimeType}',
);
}
} else {
throw Exception('Expected a List or Map but got ${data.runtimeType}');
} }
throw Exception('Expected Map with data and pagination');
} else if (response.statusCode == 401) { } else if (response.statusCode == 401) {
throw UnauthorizedException(); throw UnauthorizedException();
} else if (response.statusCode == 403) { } else if (response.statusCode == 403) {
throw AuthBlockException(); throw AuthBlockException();
} }
throw Exception('Ошибка сервера: ${response.statusCode}'); throw Exception('Ошибка сервера: ${response.statusCode}');
} }

View File

@@ -1,7 +1,9 @@
import 'package:be_happy/domain/entities/client_notification.dart'; import 'package:be_happy/domain/entities/client_notification.dart';
import '../../data/network/api_service.dart'; import '../../data/network/api_service.dart';
import '../../domain/entities/pagination_meta.dart';
import '../../domain/repositories/notification_repository.dart'; import '../../domain/repositories/notification_repository.dart';
import '../../domain/usecase/get_notifications_usecase.dart';
import '../models/client_notification_dto.dart'; import '../models/client_notification_dto.dart';
class NotificationRepositoryImpl implements NotificationRepository { class NotificationRepositoryImpl implements NotificationRepository {
@@ -35,19 +37,27 @@ class NotificationRepositoryImpl implements NotificationRepository {
} }
@override @override
Future<List<ClientNotification>> getNotifications() async { Future<NotificationsResult> getNotifications({
int page = 1,
int pageSize = 10,
}) async {
try { try {
final List<Map<String, dynamic>> data = await _apiService.getNotifications(); final response = await _apiService.getNotifications(
final notifications = data.map((json) { page: page,
final dto = ClientNotificationDto.fromJson(json); pageSize: pageSize,
return dto.toEntity(); );
}).toList();
// dev.log('NotificationRepository: Загружено ${notifications.length} уведомлений'); final notifications = (response['data'] as List)
.map((json) => ClientNotificationDto.fromJson(json).toEntity())
.toList();
return notifications; final pagination = PaginationMeta.fromJson(response['pagination']);
return NotificationsResult(
notifications: notifications,
pagination: pagination,
);
} catch (e, stackTrace) { } catch (e, stackTrace) {
// dev.log('NotificationRepository: Ошибка: $e', stackTrace: stackTrace);
throw Exception('Не удалось загрузить уведомления: $e'); throw Exception('Не удалось загрузить уведомления: $e');
} }
} }

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

@@ -192,15 +192,11 @@ class ScooterRepositoryImpl extends ScooterRepository {
} }
@override @override
Future<Result<ScooterOrder>> cancelRide(int orderId) async { Future<Result<void>> cancelRide(int orderId) async {
late final Result<ScooterOrder> result; late final Result<void> result;
try { try {
final order = await _apiService.cancelRide(orderId); await _apiService.cancelRide(orderId);
if (order != null) { result = Success(null);
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
} on AuthException catch (e) { } on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft)); result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) { } catch (e) {
@@ -246,18 +242,11 @@ class ScooterRepositoryImpl extends ScooterRepository {
} }
@override @override
Future<Result<ScooterOrder>> finishRide(int orderId, List<int> files) async { Future<Result<void>> finishRide(int orderId, List<int> files) async {
late final Result<ScooterOrder> result; late final Result<ScooterOrder> result;
try { try {
final order = await _apiService.finishRide( await _apiService.finishRide(orderId: orderId, filesId: files);
orderId: orderId, result = Success(null);
filesId: files,
);
if (order != null) {
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
} on AuthException catch (e) { } on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft)); result = Failure(AuthFailure(e.attemptsLeft));
} on WrongZoneException catch (e) { } on WrongZoneException catch (e) {
@@ -274,7 +263,6 @@ class ScooterRepositoryImpl extends ScooterRepository {
try { try {
await _apiService.payRide(orderId); await _apiService.payRide(orderId);
result = Success(null); result = Success(null);
} on AuthException catch (e) { } on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft)); result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) { } catch (e) {
@@ -345,7 +333,7 @@ class ScooterRepositoryImpl extends ScooterRepository {
cardId: cardId, cardId: cardId,
isBalance: isBalance, isBalance: isBalance,
); );
result = Success(null); result = Success(null);
} on AuthException catch (e) { } on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft)); result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) { } catch (e) {

View File

@@ -305,9 +305,9 @@ 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()));
getIt.registerFactory<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt())); getIt.registerLazySingleton<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt()));
getIt.registerFactory<EditProfileBloc>( getIt.registerFactory<EditProfileBloc>(
() => EditProfileBloc(getIt(), getIt()), () => EditProfileBloc(getIt(), getIt()),
@@ -337,7 +337,7 @@ Future<void> setupDependencies() async {
getIt.registerFactory<PaymentMethodSheetBloc>( getIt.registerFactory<PaymentMethodSheetBloc>(
() => PaymentMethodSheetBloc(getIt()), () => PaymentMethodSheetBloc(getIt(), getIt(), getIt()),
); );
getIt.registerFactory<CurrentRidesBloc>(() => CurrentRidesBloc(getIt())); getIt.registerFactory<CurrentRidesBloc>(() => CurrentRidesBloc(getIt()));

View File

@@ -32,7 +32,7 @@ class ClientNotification {
required this.createdAt, required this.createdAt,
this.canceledAt, this.canceledAt,
this.readAt, this.readAt,
}); }) ;
ClientNotification copyWith({ ClientNotification copyWith({
int? id, int? id,

View File

@@ -0,0 +1,28 @@
class PaginationMeta {
final int total;
final int lastPage;
final int currentPage;
final int totalPerPage;
final int? prevPage;
final int? nextPage;
PaginationMeta({
required this.total,
required this.lastPage,
required this.currentPage,
required this.totalPerPage,
this.prevPage,
this.nextPage,
});
factory PaginationMeta.fromJson(Map<String, dynamic> json) {
return PaginationMeta(
total: json['total'] as int,
lastPage: json['lastPage'] as int,
currentPage: json['currentPage'] as int,
totalPerPage: json['totalPerPage'] as int,
prevPage: json['prevPage'] as int?,
nextPage: json['nextPage'] as int?,
);
}
}

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

@@ -29,6 +29,9 @@ class ScooterOrder {
final String? cancelDescription; final String? cancelDescription;
final double mileage; //эту final double mileage; //эту
final double avgSpeed; //эту и снизу еще 4 есть final double avgSpeed; //эту и снизу еще 4 есть
final String payTotalPricePrint;
final String unpayTotalPricePrint;
final String totalOrderPricePrint;
ScooterOrder({ ScooterOrder({
required this.id, required this.id,
@@ -57,11 +60,16 @@ class ScooterOrder {
this.expiresAt, this.expiresAt,
this.cancelAt, this.cancelAt,
this.cancelDescription, this.cancelDescription,
required this.mileage, //эту required this.mileage,
required this.avgSpeed, //эту и снизу еще 2 есть required this.avgSpeed,
required this.payTotalPricePrint, // оплачено
required this.unpayTotalPricePrint, //неоплачено
required this.totalOrderPricePrint, //общая
}); });
factory ScooterOrder.fromJson(Map<String, dynamic> json) { factory ScooterOrder.fromJson(Map<String, dynamic> json) {
final paymentInfo = json['paymentInfo'] as Map<String, dynamic>?;
return ScooterOrder( return ScooterOrder(
id: json['id'] ?? 0, id: json['id'] ?? 0,
scooterId: json['scooterId'] ?? 0, scooterId: json['scooterId'] ?? 0,
@@ -101,8 +109,11 @@ class ScooterOrder {
? DateTime.parse(json['cancelAt']) ? DateTime.parse(json['cancelAt'])
: null, : null,
cancelDescription: json['cancelDescription'], cancelDescription: json['cancelDescription'],
mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0, //эту mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0,
avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0, //эту avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0,
payTotalPricePrint: paymentInfo?['payTotalPricePrint'] ?? '',
unpayTotalPricePrint: paymentInfo?['unpayTotalPricePrint'] ?? '',
totalOrderPricePrint: paymentInfo?['totalOrderPricePrint'] ?? '',
); );
} }

View File

@@ -3,7 +3,7 @@ class UserProfile {
String birthDate; String birthDate;
String phone; String phone;
String email; String email;
int? balance; double? balance;
int? avatarId; int? avatarId;
String? avatarUrl; String? avatarUrl;
@@ -21,7 +21,7 @@ class UserProfile {
String? name, String? name,
String? birthDate, String? birthDate,
String? email, String? email,
int? balance, double? balance,
int? avatarId, int? avatarId,
String? avatarUrl, String? avatarUrl,
}) { }) {

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'package:be_happy/domain/entities/point.dart'; import 'package:be_happy/domain/entities/point.dart';
class Zone { class Zone {
@@ -9,7 +8,10 @@ class Zone {
final String type; final String type;
final bool isActive; final bool isActive;
final String shapeType; final String shapeType;
final List<Point> points; // Трехмерная структура для поддержки MultiPolygon и дыр (holes):
// geometry[индекс_полигона][индекс_кольца][индекс_точки]
// индекс_кольца == 0 — внешняя граница, > 0 — внутренние вырезы (дыры)
final List<List<List<Point>>> geometry;
final String speedLimit; final String speedLimit;
Zone({ Zone({
@@ -19,38 +21,84 @@ class Zone {
required this.type, required this.type,
required this.isActive, required this.isActive,
required this.shapeType, required this.shapeType,
required this.points, required this.geometry,
required this.speedLimit, required this.speedLimit,
}); });
// Геттер для обратной совместимости (возвращает внешнее кольцо первого полигона)
List<Point> get points {
if (geometry.isNotEmpty && geometry[0].isNotEmpty) {
return geometry[0][0];
}
return [];
}
factory Zone.fromJson(Map<String, dynamic> json) { factory Zone.fromJson(Map<String, dynamic> json) {
final zoneCoordinates = json['coordinates'] as Map<String, dynamic>? ?? {}; final zoneCoordinates = json['coordinates'] as Map<String, dynamic>? ?? {};
final String coordsString = zoneCoordinates['coordinates'] ?? '[]'; final String coordsString = zoneCoordinates['coordinates'] ?? '[]';
final String shapeType = zoneCoordinates['type'] ?? 'Polygon'; final String shapeType = zoneCoordinates['type'] ?? 'Polygon';
List<Point> points = []; List<List<List<Point>>> parsedGeometry = [];
try { try {
final dynamic decoded = jsonDecode(coordsString); final dynamic decoded = jsonDecode(coordsString);
if (decoded is List && decoded.isNotEmpty) { if (decoded is List && decoded.isNotEmpty) {
List<dynamic> targetList = [];
if (shapeType == 'Polygon') { if (shapeType == 'Polygon') {
// У полигона структура [[[lat, lon], ...]] -> уходим на 1 уровень вглубь // Структура: [[[lon, lat], [lon, lat], ...], [hole1], ...]
targetList = decoded[0] as List<dynamic>; List<List<Point>> polygonRings = [];
} else { for (var ring in decoded) {
// У LineString структура [[lat, lon], ...] -> используем как есть if (ring is List) {
targetList = decoded; List<Point> ringPoints = [];
} for (var coord in ring) {
if (coord is List && coord.length >= 2) {
// В GeoJSON сначала идет Longitude, затем Latitude
ringPoints.add(Point(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
if (ringPoints.isNotEmpty) polygonRings.add(ringPoints);
}
}
if (polygonRings.isNotEmpty) parsedGeometry.add(polygonRings);
points = targetList.map((item) { } else if (shapeType == 'MultiPolygon') {
final List<dynamic> coords = item as List<dynamic>; // Структура: [[[[lon, lat], ...]], [[[lon, lat], ...]]]
return Point( for (var poly in decoded) {
(coords[1] as num).toDouble(), if (poly is List) {
(coords[0] as num).toDouble(), List<List<Point>> polygonRings = [];
); for (var ring in poly) {
}).toList(); if (ring is List) {
List<Point> ringPoints = [];
for (var coord in ring) {
if (coord is List && coord.length >= 2) {
ringPoints.add(Point(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
if (ringPoints.isNotEmpty) polygonRings.add(ringPoints);
}
}
if (polygonRings.isNotEmpty) parsedGeometry.add(polygonRings);
}
}
} else {
// Fallback для LineString или плоских списков
List<Point> ringPoints = [];
for (var coord in decoded) {
if (coord is List && coord.length >= 2) {
ringPoints.add(Point(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
if (ringPoints.isNotEmpty) parsedGeometry.add([ringPoints]);
}
} }
} catch (e) { } catch (e) {
print("PARSE ERROR for Zone ID ${json['id']}: $e"); print("PARSE ERROR for Zone ID ${json['id']}: $e");
@@ -64,11 +112,12 @@ class Zone {
isActive: json['isActive'] ?? false, isActive: json['isActive'] ?? false,
speedLimit: json['speedLimit'] ?? '', speedLimit: json['speedLimit'] ?? '',
shapeType: shapeType, shapeType: shapeType,
points: points, geometry: parsedGeometry,
); );
} }
@override @override
String toString() { String toString() {
return 'Zone{id: $id, title: $title, type: $type, points: $points}'; return 'Zone{id: $id, title: $title, type: $type, polygonsCount: ${geometry.length}}';
} }
} }

View File

@@ -1,5 +1,7 @@
import 'package:be_happy/domain/entities/client_notification.dart'; import 'package:be_happy/domain/entities/client_notification.dart';
import '../usecase/get_notifications_usecase.dart';
abstract class NotificationRepository { abstract class NotificationRepository {
/// Устанавливает постоянное SSE-соединение и возвращает поток уведомлений /// Устанавливает постоянное SSE-соединение и возвращает поток уведомлений
Stream<ClientNotification> getNotificationsStream(); Stream<ClientNotification> getNotificationsStream();
@@ -11,5 +13,7 @@ abstract class NotificationRepository {
void closeStream(); void closeStream();
/// получить список уведомлений /// получить список уведомлений
Future<List<ClientNotification>> getNotifications(); Future<NotificationsResult> getNotifications({
} int page = 1,
int pageSize = 10,
});}

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

@@ -27,10 +27,10 @@ abstract class ScooterRepository {
required bool isInsurance, required bool isInsurance,
}); });
Future<Result<ScooterOrder>> startRide(int orderId); Future<Result<ScooterOrder>> startRide(int orderId);
Future<Result<ScooterOrder>> cancelRide(int orderId); Future<Result<void>> cancelRide(int orderId);
Future<Result<ScooterOrder>> pauseRide(int orderId); Future<Result<ScooterOrder>> pauseRide(int orderId);
Future<Result<ScooterOrder>> resumeRide(int orderId); Future<Result<ScooterOrder>> resumeRide(int orderId);
Future<Result<ScooterOrder>> finishRide(int orderId, List<int> files); Future<Result<void>> finishRide(int orderId, List<int> files);
Future<Result<void>> payRide(int orderId); Future<Result<void>> payRide(int orderId);
Future<Result<List<ScooterOrder>>> getClientOrders(); Future<Result<List<ScooterOrder>>> getClientOrders();
Future<Result<List<int>>> uploadScooterPhotos(List<File> images); Future<Result<List<int>>> uploadScooterPhotos(List<File> images);

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

@@ -7,7 +7,7 @@ class CancelRideUsecase {
CancelRideUsecase(this.repository); CancelRideUsecase(this.repository);
Future<Result<ScooterOrder>> call(int orderId) { Future<Result<void>> call(int orderId) {
return repository.cancelRide(orderId); return repository.cancelRide(orderId);
} }
} }

View File

@@ -7,7 +7,7 @@ class FinishRideUsecase {
FinishRideUsecase(this.repository); FinishRideUsecase(this.repository);
Future<Result<ScooterOrder>> call(int orderId, List<int> files) { Future<Result<void>> call(int orderId, List<int> files) {
return repository.finishRide(orderId, files); return repository.finishRide(orderId, files);
} }
} }

View File

@@ -1,4 +1,5 @@
import '../entities/client_notification.dart'; import '../entities/client_notification.dart';
import '../entities/pagination_meta.dart';
import '../repositories/notification_repository.dart'; import '../repositories/notification_repository.dart';
class GetNotificationsUsecase { class GetNotificationsUsecase {
@@ -6,7 +7,17 @@ class GetNotificationsUsecase {
GetNotificationsUsecase(this.repository); GetNotificationsUsecase(this.repository);
Future<List<ClientNotification>> call() { Future<NotificationsResult> call({int page = 1, int pageSize = 10}) {
return repository.getNotifications(); return repository.getNotifications(page: page, pageSize: pageSize);
} }
}
class NotificationsResult {
final List<ClientNotification> notifications;
final PaginationMeta pagination;
NotificationsResult({
required this.notifications,
required this.pagination,
});
} }

View File

@@ -57,6 +57,7 @@ class MyApp extends StatelessWidget {
BlocProvider(create: (context) => getIt<VerifyCodeBloc>()), BlocProvider(create: (context) => getIt<VerifyCodeBloc>()),
BlocProvider(create: (context) => getIt<PinBloc>()), BlocProvider(create: (context) => getIt<PinBloc>()),
BlocProvider(create: (context) => getIt<MapBloc>()), BlocProvider(create: (context) => getIt<MapBloc>()),
BlocProvider(create: (context) => getIt<ProfileBloc>()),
], ],
child: BlocListener<SplashBloc, SplashState>( child: BlocListener<SplashBloc, SplashState>(
listener: (context, state) { listener: (context, state) {

View File

@@ -2,20 +2,83 @@ 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;
final int batteryLevel;
const ScooterInfoSection({
super.key,
required this.distance,
required this.location,
required this.batteryLevel,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1. Расчет оставшегося расстояния (макс. 62 км)
final double remainingKm = (62 * batteryLevel) / 100;
// Красиво форматируем: если число целое — убираем точку (например, 31 км вместо 31.0 км)
final String kmText = remainingKm % 1 == 0
? '${remainingKm.toInt()} км'
: '${remainingKm.toStringAsFixed(1)} км';
// 2. Расчет оставшегося времени (макс. 2.5 часа = 150 минут)
final int totalMinutes = ((150 * batteryLevel) / 100).round();
final int hours = totalMinutes ~/ 60;
final int minutes = totalMinutes % 60;
// Формируем строку времени в зависимости от получившихся значений
String timeText = '';
if (hours > 0 && minutes > 0) {
timeText = '$hours ч $minutes мин';
} else if (hours > 0) {
timeText = '$hours ч';
} else {
timeText = '$minutes мин';
}
final int walkingMinutes = (distance / 80).round().clamp(1, 999);
final int walkHours = walkingMinutes ~/ 60;
final int walkMins = walkingMinutes % 60;
String walkingText = '';
if (walkHours > 0 && walkMins > 0) {
walkingText = '$walkHours ч $walkMins мин';
} else if (walkHours > 0) {
walkingText = '$walkHours ч';
} else {
walkingText = '$walkingMinutes мин';
}
return Column( 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(
ScooterInfoItem(icon: 'assets/icons/location.png', text: 'пр. Московский, 33'), icon: 'assets/icons/bolt.png',
text: '$kmText или $timeText',
),
ScooterInfoItem(
icon: 'assets/icons/speed.png',
text: 'max = 25 км/ч',
),
ScooterInfoItem(
icon: 'assets/icons/location.png',
text: location,
),
Row( Row(
children: [ children: [
ScooterInfoItem(icon: 'assets/icons/person.png', text: '120 м'), ScooterInfoItem(
SizedBox(width: 16), icon: 'assets/icons/person.png',
ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'), text: distance.toInt() >= 1000
? '${((distance.toInt()) / 1000).toStringAsFixed(1)} km'
: '${distance.toInt()} m',
),
const SizedBox(width: 16),
ScooterInfoItem(
icon: 'assets/icons/time.png',
text: walkingText,
),
], ],
), ),
], ],

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, scooter.distance]);
}, },
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, scooter.distance]);
}, },
), ),
), ),

View File

@@ -218,6 +218,9 @@ class _RideCardState extends State<_RideCard> {
} else { } else {
displayTime = _elapsedTime; displayTime = _elapsedTime;
} }
final isExpired = isReserved && (_reservationTime - _elapsedTime).isNegative;
final timeString = _formatDuration(displayTime); final timeString = _formatDuration(displayTime);
final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный"; final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный";
final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa); final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa);
@@ -347,7 +350,8 @@ class _RideCardState extends State<_RideCard> {
height: 32, height: 32,
width: 100, width: 100,
fontSize: 11, fontSize: 11,
onTap: () { enabled: !isExpired,
onTap: !isExpired ? () {
if (isReserved) { if (isReserved) {
Navigator.pop(context); Navigator.pop(context);
showModalBottomSheet( showModalBottomSheet(
@@ -374,7 +378,7 @@ class _RideCardState extends State<_RideCard> {
), ),
); );
} }
}, } : null,
), ),
), ),
], ],

View File

@@ -174,7 +174,7 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
if (widget.showBalance) ...[ if (widget.showBalance) ...[
PaymentOption( PaymentOption(
title: 'Баланс', title: 'Баланс',
subtitle: '${state.balance.toStringAsFixed(2)} BYN', subtitle: '${state.balance} баллов',
isSelected: _selectedPaymentMethod == -1, isSelected: _selectedPaymentMethod == -1,
onTap: () { onTap: () {
setState(() { setState(() {
@@ -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,8 @@ 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/get_profile_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';
@@ -39,6 +41,24 @@ class _TariffSheetState extends State<TariffSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double remainingKm = (62 * widget.scooter.batteryLevel) / 100;
final String kmText = remainingKm % 1 == 0
? '${remainingKm.toInt()} км'
: '${remainingKm.toStringAsFixed(1)} км';
final int totalMinutes = ((150 * widget.scooter.batteryLevel) / 100).round();
final int hours = totalMinutes ~/ 60;
final int minutes = totalMinutes % 60;
String timeText = '';
if (hours > 0 && minutes > 0) {
timeText = '$hours ч $minutes мин';
} else if (hours > 0) {
timeText = '$hours ч';
} else {
timeText = '$minutes мин';
}
return BlocConsumer<TariffSheetBloc, TariffSheetState>( return BlocConsumer<TariffSheetBloc, TariffSheetState>(
listener: (context, state) { listener: (context, state) {
if (state.status == TariffSheetStatus.failure && state.errorMessage != null) { if (state.status == TariffSheetStatus.failure && state.errorMessage != null) {
@@ -134,7 +154,6 @@ class _TariffSheetState extends State<TariffSheet> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🔹 HEADER
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row( child: Row(
@@ -226,7 +245,7 @@ class _TariffSheetState extends State<TariffSheet> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Заряда хватит на 4 часа 17 минут\nили 47 км', 'Заряда хватит на $kmText или $timeText',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 13,
@@ -288,10 +307,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 +320,8 @@ class _TariffSheetState extends State<TariffSheet> {
builder: (innerContext) => BlocProvider( builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc( create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(), getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
)..add(PaymentMethodSheetStarted()), )..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet( child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard, initialSelectedCard: state.useBalance ? null : state.selectedCard,
@@ -381,7 +402,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 +514,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 +557,6 @@ class _TariffCard extends StatelessWidget {
], ],
), ),
// Кнопка-иконка в верхнем правом углу
Positioned( Positioned(
top: 0, top: 0,
right: 0, right: 0,
@@ -550,7 +566,7 @@ class _TariffCard extends StatelessWidget {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => TariffInfoSheet( builder: (_) => TariffInfoSheet(
tariff: tariff, tariff: tariff,
isInsurance: isInsurance, isInsurance: isInsurance,
onChanged: (val) { onChanged: (val) {

View File

@@ -63,68 +63,90 @@ class SideMenu extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
// Пункты меню
Column( Column(
children: [ children: [
_MenuItem( _MenuItem(
icon: 'assets/icons/person_icon.png', icon: 'assets/icons/person_icon.png',
title: 'Профиль', title: 'Профиль',
onTap: () => { onTap: () {
context.go('/home/profile'), Navigator.pop(context);
context.go('/home/profile');
}, },
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/list_star_icon.png', icon: 'assets/icons/list_star_icon.png',
title: 'История поездок', title: 'История поездок',
onTap: () => context.go("/home/order-history"), onTap: () {
Navigator.pop(context);
context.go("/home/order-history");
},
), ),
const Divider(color: Colors.white30, height: 1), const Divider(color: Colors.white30, height: 1),
_MenuItem( _MenuItem(
icon: 'assets/icons/creditcard_icon.png', icon: 'assets/icons/creditcard_icon.png',
title: 'Способ оплаты', title: 'Способ оплаты',
onTap: () => context.go("/home/payment-methods"), onTap: () {
Navigator.pop(context);
context.go("/home/payment-methods");
},
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/promo_icon.png', icon: 'assets/icons/promo_icon.png',
title: 'Промокоды', title: 'Промокоды',
onTap: () => context.go("/home/promo"), onTap: () {
Navigator.pop(context);
context.go("/home/promo");
},
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/plans_icon.png', icon: 'assets/icons/plans_icon.png',
title: 'Абонементы', title: 'Абонементы',
onTap: () => context.push("/home/subscriptions"), onTap: () {
Navigator.pop(context);
context.push("/home/subscriptions");
},
), ),
const Divider(color: Colors.white30, height: 1), const Divider(color: Colors.white30, height: 1),
_MenuItem( _MenuItem(
icon: 'assets/icons/magazine_icon.png', icon: 'assets/icons/magazine_icon.png',
title: 'Правила пользования самокатом', title: 'Правила пользования самокатом',
onTap: () => context.go("/home/rules") onTap: () {
Navigator.pop(context);
context.go("/home/rules");
}
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/headphones_icon.png', icon: 'assets/icons/headphones_icon.png',
title: 'Техподдержка', title: 'Техподдержка',
onTap: () => { onTap: () {
context.go('/home/support'), Navigator.pop(context);
context.go('/home/support');
}, },
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/doc_icon.png', icon: 'assets/icons/doc_icon.png',
title: 'Документы', title: 'Документы',
onTap: () => context.go("/home/documents"), onTap: () {
Navigator.pop(context);
context.go("/home/documents");
},
), ),
_MenuItem( _MenuItem(
icon: 'assets/icons/news_icon.png', icon: 'assets/icons/news_icon.png',
title: 'Новости', title: 'Новости',
onTap: () => context.go("/home/news"), onTap: () {
Navigator.pop(context);
context.go("/home/news");
},
), ),
const Divider(color: Colors.white30, height: 1), const Divider(color: Colors.white30, height: 1),
_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,8 @@
sealed class NotificationsEvent {} sealed class NotificationsEvent {}
class NotificationsFetchRequested extends NotificationsEvent {} class NotificationsFetchRequested extends NotificationsEvent {
final int page;
NotificationsFetchRequested({this.page = 1});
}
class NotificationsLoadMoreRequested extends NotificationsEvent {}

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

@@ -13,3 +13,5 @@ class AuthCheckRequested extends SplashEvent {}
class AuthStarted extends SplashEvent {} class AuthStarted extends SplashEvent {}
class PinVerificationSuccess extends SplashEvent {} class PinVerificationSuccess extends SplashEvent {}
class AuthLogoutRequested extends SplashEvent {}

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 {}
@@ -17,4 +18,10 @@ class ToggleAgreementEvent extends SubscriptionDetailsEvent {
ToggleAgreementEvent(this.value); 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 { 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,11 @@ class AppRouter {
type: MaterialType.transparency, type: MaterialType.transparency,
child: BlocProvider( child: BlocProvider(
create: (context) => create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()), PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
),
child: PaymentMethodSheet(), child: PaymentMethodSheet(),
), ),
), ),
@@ -297,12 +324,20 @@ class AppRouter {
], ],
), ),
GoRoute( GoRoute(
path: 'scooter/:id', path: 'scooter/:id',
builder: (context, state) => builder: (context, state) {
BlocProvider( List<dynamic> extras = [];
if (state.extra != null) {
extras = state.extra as List<dynamic>;
}
String address = extras[0];
double distance = extras[1];
return BlocProvider(
create: (context) => getIt<ScooterDetailBloc>(), create: (context) => getIt<ScooterDetailBloc>(),
child: ScooterDetailScreen(), child: ScooterDetailScreen(address: address, distance: distance),
), );
}
), ),
GoRoute( GoRoute(
path: 'order-photos/:orderId', path: 'order-photos/:orderId',
@@ -371,6 +406,7 @@ class AppRouter {
create: (context) => create: (context) =>
SubscriptionDetailsBloc( SubscriptionDetailsBloc(
getIt<GetSubscriptionByIdUsecase>(), getIt<GetSubscriptionByIdUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<ActivateSubscriptionUsecase>(), getIt<ActivateSubscriptionUsecase>(),
getIt<GetClientSubscriptionsUsecase>(), getIt<GetClientSubscriptionsUsecase>(),
) )
@@ -394,39 +430,44 @@ class AppRouter {
builder: (context, state) => const NewsScreen(), builder: (context, state) => const NewsScreen(),
), ),
GoRoute( GoRoute(
path: 'qr-info', path: 'qr-info',
builder: (context, state) => const QRScanInfoScreen(), builder: (context, state) => const QRScanInfoScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: 'qr-scan', path: 'qr-scan',
builder: (context, state) => const QrScanScreen(), builder: (context, state) => const QrScanScreen(),
),
GoRoute(
path: 'qr-input',
builder: (context, state) => BlocProvider(
create: (context) =>
ScooterCodeBloc(
getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>(),
),
child: ScooterCodeInputScreen(),
), ),
), GoRoute(
] path: 'qr-input',
builder: (context, state) =>
BlocProvider(
create: (context) =>
ScooterCodeBloc(
getScooterByTitleUsecase: getIt<
GetScooterByTitleUsecase>(),
),
child: ScooterCodeInputScreen(),
),
),
]
), ),
GoRoute( GoRoute(
path: 'payment-methods', path: 'payment-methods',
builder: (context, state) => builder: (context, state) {
BlocProvider( final refreshId = state.uri.queryParameters['refreshId'];
create: (context) =>
PaymentMethodsBloc( return BlocProvider(
getIt<GetPaymentCardsUsecase>(), key: refreshId != null ? ValueKey(refreshId) : null,
getIt<RemovePaymentCardUsecase>(), create: (context) => PaymentMethodsBloc(
getIt<SetMainPaymentCardUsecase>(), getIt<GetPaymentCardsUsecase>(),
getIt<GetProfileUseCase>(), getIt<RemovePaymentCardUsecase>(),
) getIt<SetMainPaymentCardUsecase>(),
..add(PaymentMethodsStarted()), getIt<GetProfileUseCase>(),
child: PaymentMethodsScreen(), getIt<AddPaymentCardUsecase>(),
), )..add(PaymentMethodsStarted()),
child: const PaymentMethodsScreen(),
);
},
routes: [ routes: [
GoRoute( GoRoute(
path: 'add-card', path: 'add-card',
@@ -450,13 +491,35 @@ class AppRouter {
..add(LoadTopUpData()), ..add(LoadTopUpData()),
child: TopUpScreen(), child: TopUpScreen(),
), ),
routes: [
GoRoute(
path: 'result/:status',
builder: (context, state) {
final modeParam = state.pathParameters['status'];
CardBindingStatus status = CardBindingStatus.success;
String title = 'Успешно!';
if (modeParam == 'failed') {
status = CardBindingStatus.failed;
title = 'Извините, что-то\nпошло не так';
}
return CardBindingResultScreen(
status: status,
title: title,
cardImagePath: 'assets/icons/card_result_screen.png',
);
},
),
],
), ),
], ],
), ),
GoRoute( GoRoute(
path: 'order-history', path: 'order-history',
builder: (context, state) => const OrderHistoryScreen(), builder: (context, state) => const OrderHistoryScreen(),
routes: [] routes: []
), ),
GoRoute( GoRoute(
path: 'notifications', path: 'notifications',
@@ -466,6 +529,24 @@ class AppRouter {
), ),
], ],
onException: (context, state, router) {
final location = state.uri.toString();
if (location.startsWith('com.behappy.app://')) {
String normalizedPath = location
.replaceFirst('com.behappy.app://', '/')
.replaceAll('/?', '?')
.replaceAll(' ', '');
print("Нормализованный путь диплинка: $normalizedPath");
router.go(normalizedPath);
} else {
print('GoRouter Exception: ${state.error}');
}
},
observers: [BotToastNavigatorObserver()], observers: [BotToastNavigatorObserver()],
redirect: (BuildContext context, GoRouterState state) { redirect: (BuildContext context, GoRouterState state) {
@@ -476,45 +557,59 @@ class AppRouter {
print(splashBloc.state); print(splashBloc.state);
print(state.uri.toString()); print(state.uri.toString());
if (authState is AuthInitial && currentLocation != '/splash') { if (authState is AuthInitial) {
print("splash"); if (currentLocation != '/splash') return '/splash';
return '/splash'; return null;
} }
if (authState is AuthFirstLaunch && // 2. Первый запуск или пользователь не авторизован
currentLocation != '/login' && if (authState is AuthFirstLaunch || authState is AuthUnauthenticated) {
currentLocation != '/privacy-policy' && final isAllowed = currentLocation == '/login' ||
currentLocation != '/license-agreement') { currentLocation.startsWith('/verify') ||
print("login"); currentLocation == '/privacy-policy' ||
return '/login'; currentLocation == '/license-agreement';
if (!isAllowed) return '/login';
return null;
} }
if (authState is AuthUnauthenticated && // 3. Авторизация в процессе (переход от ввода телефона/SMS к PIN-коду)
currentLocation != '/login' && if (authState is AuthInProgress) {
currentLocation != '/privacy-policy' && final isAllowed = currentLocation == '/login' ||
currentLocation != '/license-agreement') { currentLocation.startsWith('/verify') ||
print("login2"); currentLocation == '/pin' || // ← Теперь роутер пустит сюда!
return '/login'; currentLocation == '/privacy-policy' ||
currentLocation == '/license-agreement';
if (!isAllowed) return '/login';
return null;
} }
// 4. Пользователь авторизован, но PIN еще не проверен
if (authState is AuthAuthenticated) { if (authState is AuthAuthenticated) {
final isComingFromStart = final isComingFromStart = currentLocation == '/splash' ||
currentLocation == '/splash' || currentLocation == '/login' ||
currentLocation == '/login' || currentLocation.startsWith('/verify') ||
currentLocation == '/phone'; currentLocation == '/phone';
if (isComingFromStart) { if (isComingFromStart) {
print("redirecting to pin check"); print("redirecting to pin check");
return '/pin'; return '/pin';
} }
return null;
} }
// 5. Пользователь полностью авторизован и PIN успешно верифицирован
if (authState is AuthPinVerified) { if (authState is AuthPinVerified) {
if (currentLocation == '/splash' || final isComingFromAuth = currentLocation == '/splash' ||
currentLocation == '/login' || currentLocation == '/login' ||
currentLocation == '/pin') { currentLocation.startsWith('/verify') ||
currentLocation == '/pin';
if (isComingFromAuth) {
return '/home'; return '/home';
} }
return null;
} }
return null; return null;

View File

@@ -0,0 +1,173 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../components/gradient_button.dart';
enum CardBindingStatus { success, failed }
class CardBindingResultScreen extends StatefulWidget {
final CardBindingStatus status;
final String title;
final String cardImagePath;
const CardBindingResultScreen({
super.key,
required this.status,
required this.title,
required this.cardImagePath,
});
@override
State<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) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp");
}
});
}
}
@override
void dispose() {
_autoPopTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isSuccess = widget.status == CardBindingStatus.success;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF1B284F),
Color(0xFF0F172A),
],
),
),
child: SafeArea(
child: Stack(
children: [
// Positioned(
// bottom: 50,
// left: 0,
// right: 0,
// child: Image.asset(
// 'assets/wave.png',
// fit: BoxFit.cover,
// ),
// ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// const Spacer(),
Text(
widget.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 40),
Image.asset(
isSuccess
? 'assets/icons/success.png'
: 'assets/icons/failed.png',
width: 140,
height: 140,
),
const SizedBox(height: 40),
if (isSuccess) ...[
Padding(
padding: const EdgeInsets.only(bottom: 40.0),
// child: Image.asset(
// widget.cardImagePath,
// width: double.infinity,
// fit: BoxFit.contain,
// ),
),
] else ...[
const Text(
'Проверьте подключение к\nинтернету и попробуйте снова',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF94A3B8),
fontSize: 16,
height: 1.4,
),
),
const SizedBox(height: 40),
GradientButton(
text: "Попробовать снова",
onTap: () {
final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp");
},
enabled: true,
showArrows: true,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: TextButton(
onPressed: ()
{
final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp");
},
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
backgroundColor: const Color(0xFF0F172A).withOpacity(0.4),
),
child: const Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
],
],
),
),
],
),
),
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/user_profile.dart'; import '../../domain/entities/user_profile.dart';
import '../components/custom_app_bar.dart'; import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
@@ -106,7 +107,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
} }
if (state.isSuccess) { if (state.isSuccess) {
context.read<ProfileBloc>().add(ProfileUpdated()); getIt<ProfileBloc>().add(ProfileUpdated());
context.pop(); context.pop();
} }
if (state.error != null) { if (state.error != null) {

View File

@@ -4,24 +4,9 @@ import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.dart';
import 'package:be_happy/domain/entities/client_notification.dart'; import 'package:be_happy/domain/entities/client_notification.dart';
import 'package:be_happy/domain/usecase/book_scooter_usecase.dart';
import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart';
import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart';
import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart';
import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart';
import 'package:be_happy/domain/usecase/get_scooter_usecase.dart';
import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart';
import 'package:be_happy/presentation/components/fine_notification_card.dart'; import 'package:be_happy/presentation/components/fine_notification_card.dart';
import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart'; import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart';
import 'package:be_happy/presentation/components/payment_notification_card.dart'; import 'package:be_happy/presentation/components/payment_notification_card.dart';
import 'package:be_happy/presentation/components/sheet/current_rides_sheet.dart';
import 'package:be_happy/presentation/components/sheet/map_settings_sheet.dart';
import 'package:be_happy/presentation/components/sheet/tariff_sheet.dart';
import 'package:be_happy/presentation/event/current_rides_event.dart';
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart';
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -32,18 +17,12 @@ import '../../core/app_colors.dart';
import '../../di/service_locator.dart'; import '../../di/service_locator.dart';
import '../../domain/entities/scooter.dart'; import '../../domain/entities/scooter.dart';
import '../../domain/entities/zone.dart'; import '../../domain/entities/zone.dart';
import '../../domain/usecase/get_address_by_point_usecase.dart';
import '../../domain/usecase/get_available_tariffs_usecase.dart';
import '../../domain/usecase/get_notifications_stream_usecase.dart'; import '../../domain/usecase/get_notifications_stream_usecase.dart';
import '../components/notification_toast.dart';
import '../components/sheet/scooter_bottom_sheet.dart';
import '../components/side_menu.dart'; import '../components/side_menu.dart';
import '../components/unpaid_order_notification_card.dart'; import '../components/unpaid_order_notification_card.dart';
import '../event/map_event.dart'; import '../event/map_event.dart';
import '../event/scooter_detail_modal_event.dart';
import '../state/map_state.dart'; import '../state/map_state.dart';
import '../viewmodel/map_bloc.dart'; import '../viewmodel/map_bloc.dart';
import '../viewmodel/scooter_detail_modal_bloc.dart';
class MapScreen extends StatefulWidget { class MapScreen extends StatefulWidget {
const MapScreen({super.key}); const MapScreen({super.key});
@@ -609,28 +588,33 @@ class _MapScreenState extends State<MapScreen> {
if (zones == null || zones.isEmpty) return []; if (zones == null || zones.isEmpty) return [];
List<MapObject> objects = []; List<MapObject> objects = [];
List<LinearRing> driveOuterRings = [];
List<LinearRing> allZoneHoles = [];
for (var zone in zones) { for (var zone in zones) {
var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); if (zone.type == "Drive") {
for (var polygonData in zone.geometry) {
if (polygonData.isEmpty) continue;
var cleanPoints = <Point>[]; var outerPoints = polygonData[0].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList();
for (var p in points) {
if (cleanPoints.isEmpty || cleanPoints.last != p) { var cleanPoints = <Point>[];
cleanPoints.add(p); for (var p in outerPoints) {
if (cleanPoints.isEmpty || cleanPoints.last != p) {
cleanPoints.add(p);
}
}
if (cleanPoints.length > 2) {
if (cleanPoints.first != cleanPoints.last) {
cleanPoints.add(cleanPoints.first);
}
driveOuterRings.add(LinearRing(points: cleanPoints));
}
} }
} }
if (cleanPoints.length > 2) {
if (cleanPoints.first != cleanPoints.last) {
cleanPoints.add(cleanPoints.first);
}
allZoneHoles.add(LinearRing(points: cleanPoints));
}
} }
// Добавляем глобальную маску (все, что НЕ Drive — затеняется красным)
objects.add( objects.add(
PolygonMapObject( PolygonMapObject(
mapId: const MapObjectId('global_inverse_mask'), mapId: const MapObjectId('global_inverse_mask'),
@@ -641,7 +625,7 @@ class _MapScreenState extends State<MapScreen> {
Point(latitude: -85, longitude: 179.9), Point(latitude: -85, longitude: 179.9),
Point(latitude: -85, longitude: -179.9), Point(latitude: -85, longitude: -179.9),
]), ]),
innerRings: allZoneHoles, innerRings: driveOuterRings, // Вырезаем только зоны Drive
), ),
strokeWidth: 0, strokeWidth: 0,
fillColor: Colors.red.withOpacity(0.15), fillColor: Colors.red.withOpacity(0.15),
@@ -649,27 +633,63 @@ class _MapScreenState extends State<MapScreen> {
), ),
); );
// 2. Отрисовываем каждую зону (включая MultiPolygon и вложенные NotDrive/Finish)
for (var zone in zones) { for (var zone in zones) {
Color borderColor; Color borderColor;
Color fillColor;
// Стилизация в зависимости от типа зоны
if (zone.type == "Drive") { if (zone.type == "Drive") {
borderColor = const Color(0xFF5ECD4C); borderColor = const Color(0xFF5ECD4C); // Зеленый бордюр
fillColor = Colors.transparent; // Прозрачная внутри маски
} else if (zone.type == "NotDrive") { } else if (zone.type == "NotDrive") {
borderColor = const Color(0xFFEF4444); borderColor = const Color(0xFFEF4444); // Красный бордюр
fillColor = const Color(0xFFEF4444).withOpacity(0.3); // Красная полупрозрачная заливка для наглядности внутри Drive
} else { } else {
borderColor = const Color(0xFFA78BFA); borderColor = const Color(0xFFA78BFA); // Фиолетовый бордюр (Парковка)
fillColor = const Color(0xFFA78BFA).withOpacity(0.25); // Фиолетовая заливка
} }
objects.add( int polyIndex = 0;
PolylineMapObject( for (var polygonData in zone.geometry) {
mapId: MapObjectId('zone_contour_${zone.id}'), if (polygonData.isEmpty) continue;
polyline: Polyline(
points: zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(), // Строим внешнее кольцо текущего полигона
var outerPoints = polygonData[0].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList();
if (outerPoints.first != outerPoints.last) {
outerPoints.add(outerPoints.first);
}
LinearRing outerRing = LinearRing(points: outerPoints);
// Строим внутренние кольца (дыры), если они есть у этого полигона
List<LinearRing> innerRings = [];
for (int i = 1; i < polygonData.length; i++) {
var innerPoints = polygonData[i].map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList();
if (innerPoints.isNotEmpty) {
if (innerPoints.first != innerPoints.last) {
innerPoints.add(innerPoints.first);
}
innerRings.add(LinearRing(points: innerPoints));
}
}
// Рендерим полноценный PolygonMapObject (он поддерживает и заливку, и обводку)
objects.add(
PolygonMapObject(
mapId: MapObjectId('zone_poly_${zone.type}_${zone.id}_$polyIndex'),
polygon: Polygon(
outerRing: outerRing,
innerRings: innerRings,
),
strokeColor: borderColor,
strokeWidth: 2.0,
fillColor: fillColor,
// Поднимаем зоны ограничений и парковок выше маски и контуров Drive
zIndex: zone.type == "Drive" ? 1 : 2,
), ),
strokeColor: borderColor, );
strokeWidth: 2.0, polyIndex++;
zIndex: 1, }
),
);
} }
return objects; return objects;
@@ -684,7 +704,10 @@ class _MapScreenState extends State<MapScreen> {
child: Builder( child: Builder(
builder: (innerContext) => _RoundIconButton( builder: (innerContext) => _RoundIconButton(
icon: Icons.menu, icon: Icons.menu,
onPressed: () => {Scaffold.of(innerContext).openDrawer()}, onPressed: () {
context.read<MapBloc>().add(FetchProfileData());
Scaffold.of(innerContext).openDrawer();
},
), ),
), ),
), ),

View File

@@ -23,7 +23,10 @@ class NotificationsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _NotificationsScreenContent(); return BlocProvider(
create: (context) => getIt<NotificationsBloc>()..add(NotificationsFetchRequested(page: 1)),
child: _NotificationsScreenContent(),
);
} }
} }
@@ -36,21 +39,31 @@ class _NotificationsScreenContent extends StatefulWidget {
class _NotificationsScreenContentState extends State<_NotificationsScreenContent> { class _NotificationsScreenContentState extends State<_NotificationsScreenContent> {
NotificationFilter _filter = NotificationFilter.all; NotificationFilter _filter = NotificationFilter.all;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _setFilter(NotificationFilter filter) { void _setFilter(NotificationFilter filter) {
setState(() { setState(() => _filter = filter);
_filter = filter; _scrollController.jumpTo(0);
}); context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return NotificationsView(
create: (context) => getIt<NotificationsBloc>()..add(NotificationsFetchRequested()), filter: _filter,
child: NotificationsView( onFilterChanged: _setFilter,
filter: _filter, scrollController: _scrollController,
onFilterChanged: _setFilter,
),
); );
} }
} }
@@ -58,11 +71,13 @@ class _NotificationsScreenContentState extends State<_NotificationsScreenContent
class NotificationsView extends StatelessWidget { class NotificationsView extends StatelessWidget {
final NotificationFilter filter; final NotificationFilter filter;
final ValueChanged<NotificationFilter> onFilterChanged; final ValueChanged<NotificationFilter> onFilterChanged;
final ScrollController? scrollController;
const NotificationsView({ const NotificationsView({
super.key, super.key,
this.filter = NotificationFilter.all, this.filter = NotificationFilter.all,
required this.onFilterChanged, required this.onFilterChanged,
this.scrollController,
}); });
@override @override
@@ -119,7 +134,7 @@ class NotificationsView extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
context.read<NotificationsBloc>().add(NotificationsFetchRequested()); context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
}, },
child: const Text('Повторить'), child: const Text('Повторить'),
), ),
@@ -138,9 +153,6 @@ class NotificationsView extends StatelessWidget {
return n.category == NotificationCategory.payment; return n.category == NotificationCategory.payment;
case NotificationFilter.order: case NotificationFilter.order:
return n.category == NotificationCategory.scooter; return n.category == NotificationCategory.scooter;
// || n.category == NotificationCategory.adminInfo
// || n.category == NotificationCategory.companyInfo;
default: default:
return true; return true;
} }
@@ -152,14 +164,39 @@ class NotificationsView extends StatelessWidget {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
context.read<NotificationsBloc>().add(NotificationsFetchRequested()); context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
}, },
child: ListView.builder( child: NotificationListener<ScrollNotification>(
padding: const EdgeInsets.symmetric(horizontal: 20), onNotification: (scrollInfo) {
itemCount: filtered.length, if (scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 50 &&
itemBuilder: (context, index) { scrollInfo.metrics.maxScrollExtent > 0 &&
return _NotificationCard(notification: filtered[index]); state.hasMore &&
!state.isLoadingMore &&
state.status == NotificationsStatus.success) {
context.read<NotificationsBloc>().add(NotificationsLoadMoreRequested());
}
return false;
}, },
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: filtered.length + (state.hasMore && filtered.length >= 10 ? 1 : 0),
itemBuilder: (context, index) {
if (index == filtered.length && state.hasMore && filtered.length >= 10) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
),
),
);
}
return _NotificationCard(notification: filtered[index]);
},
),
), ),
); );
}, },
@@ -308,16 +345,23 @@ class _NotificationCard extends StatelessWidget {
} }
String _formatDate(DateTime date) { String _formatDate(DateTime date) {
final localDate = date.toLocal();
final now = DateTime.now(); final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) { if (localDate.year == now.year &&
return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; localDate.month == now.month &&
} else if (difference.inDays == 1) { localDate.day == now.day) {
return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; return 'Сегодня, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
} else {
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}';
} }
final yesterday = now.subtract(const Duration(days: 1));
if (localDate.year == yesterday.year &&
localDate.month == yesterday.month &&
localDate.day == yesterday.day) {
return 'Вчера, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
}
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
} }
String _getTypeLabel(NotificationType type) { String _getTypeLabel(NotificationType type) {

View File

@@ -5,6 +5,8 @@ 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/get_profile_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';
@@ -133,11 +135,6 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildInfoLabel('Начислено баллов:'),
_buildValueRow('points_icon.png', '2 балла'),
const SizedBox(height: 24),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -148,7 +145,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
_buildInfoLabel('Стоимость поездки:'), _buildInfoLabel('Стоимость поездки:'),
_buildValueRow( _buildValueRow(
'money_icon.png', 'money_icon.png',
'17,17 BYN', order?.totalOrderPricePrint ?? '- BYN',
), ),
], ],
), ),
@@ -161,7 +158,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
_buildInfoLabel('Оплачено:'), _buildInfoLabel('Оплачено:'),
_buildValueRow( _buildValueRow(
'money_icon.png', 'money_icon.png',
'10,00 BYN', order?.payTotalPricePrint ?? '0,00 BYN',
), ),
], ],
), ),
@@ -192,9 +189,9 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
width: 40, width: 40,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'7,17 BYN', order?.unpayTotalPricePrint ?? '0,00 BYN',
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -212,10 +209,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 +222,8 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
builder: (innerContext) => BlocProvider( builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc( create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(), getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
)..add(PaymentMethodSheetStarted()), )..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet( child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard, initialSelectedCard: state.useBalance ? null : state.selectedCard,

View File

@@ -80,7 +80,7 @@ class PaymentMethodsScreen extends StatelessWidget {
); );
} }
Widget _buildBalanceCard(BuildContext context, int balance) { Widget _buildBalanceCard(BuildContext context, double balance) {
return Container( return Container(
padding: EdgeInsets.all(20), padding: EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -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

@@ -77,19 +77,20 @@ class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
return BlocConsumer<VerifyCodeBloc, VerifyCodeState>( return BlocConsumer<VerifyCodeBloc, VerifyCodeState>(
listener: (context, state) { listener: (context, state) {
if (state.isSuccess) { if (state.isSuccess) {
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,9 @@ 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;
final double distance;
const ScooterDetailScreen({super.key, required this.address, required this.distance});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -83,7 +85,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: distance, location: address, batteryLevel: scooter?.batteryLevel ?? 0,)),
const SizedBox(width: 20), const SizedBox(width: 20),
], ],
), ),

View File

@@ -3,13 +3,22 @@ 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/get_profile_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 +59,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 +115,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 +127,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 +143,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 +155,78 @@ class SubscriptionDetailsScreen extends StatelessWidget {
), ),
); );
} }
Widget _buildCardSelector(DetailsContentState state, BuildContext context) {
if (state.selectedCard != null) {
return PaymentOption(
title: "Карта",
subtitle: state.selectedCard.cardLastNumber,
isSelected: true,
onTap: () async {
final selectedCard = await showModalBottomSheet<PaymentCard>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
)..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 +300,7 @@ class _ActionCard extends StatelessWidget {
), ),
); );
} }
} }
class _PriceRow extends StatelessWidget { class _PriceRow extends StatelessWidget {

View File

@@ -1,3 +1,4 @@
import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart';
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';
@@ -6,18 +7,23 @@ 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/get_profile_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/payment_option.dart';
import '../components/sheet/payment_method_sheet.dart'; import '../components/sheet/payment_method_sheet.dart';
import '../event/payment_method_sheet_event.dart'; import '../event/payment_method_sheet_event.dart';
import '../event/payment_methods_event.dart';
import '../event/top_up_event.dart'; import '../event/top_up_event.dart';
import '../state/top_up_state.dart'; import '../state/top_up_state.dart';
import '../viewmodel/payment_method_sheet_bloc.dart'; import '../viewmodel/payment_method_sheet_bloc.dart';
import '../viewmodel/top_up_bloc.dart'; import '../viewmodel/top_up_bloc.dart';
class TopUpScreen extends StatelessWidget { class TopUpScreen extends StatelessWidget {
const TopUpScreen({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -26,55 +32,77 @@ class TopUpScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: BlocListener<TopUpBloc, TopUpState>(
children: [ listener: (context, state) {
const SizedBox(height: 16), if (state is TopUpPaymentSuccess) {
const CustomAppBar(title: 'Пополнение баланса'), context.go('/home/payment-methods/top-up/result/success');
const SizedBox(height: 20), } else if (state is TopUpPaymentFailure) {
context.go('/home/payment-methods/top-up/result/failed');
}
},
child: Column(
children: [
const SizedBox(height: 16),
const CustomAppBar(title: 'Пополнение баланса'),
const SizedBox(height: 20),
BlocBuilder<TopUpBloc, TopUpState>(
builder: (context, state) {
if (state is TopUpLoading) {
return const Expanded(
child: Center(child: CircularProgressIndicator()),
);
}
BlocBuilder<TopUpBloc, TopUpState>( if (state is TopUpLoaded) {
builder: (context, state) { if (state.isProcessingPayment) {
if (state.isLoading) { return const Expanded(
return const Center(child: CircularProgressIndicator()); child: Center(child: CircularProgressIndicator()),
} );
}
return Expanded( return Expanded(
child: Stack( child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTariffList(state, context), Column(
const SizedBox(height: 30), crossAxisAlignment: CrossAxisAlignment.start,
_buildPriceInfo(state), children: [
const SizedBox(height: 40), _buildTariffList(state, context),
const Text( const SizedBox(height: 30),
'Способ оплаты', _buildPriceInfo(state),
style: TextStyle(color: Colors.white70), 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),
],
), ),
const SizedBox(height: 15),
_buildCardSelector(state, context),
const SizedBox(height: 20),
_buildAgreement(state, context),
const Spacer(),
_buildPayButton(state),
const SizedBox(height: 30),
], ],
), ),
], );
), }
);
}, return const SizedBox.shrink();
), },
], ),
],
),
), ),
), ),
) ),
), ),
); );
} }
Widget _buildTariffList(TopUpState state, BuildContext context) { Widget _buildTariffList(TopUpLoaded state, BuildContext context) {
return SizedBox( return SizedBox(
height: 120, height: 120,
child: ListView.separated( child: ListView.separated(
@@ -85,14 +113,11 @@ class TopUpScreen extends StatelessWidget {
final tariff = state.certificates[index]; final tariff = state.certificates[index];
final isSelected = state.selectedTariff == tariff; final isSelected = state.selectedTariff == tariff;
return GestureDetector( return GestureDetector(
onTap: () => onTap: () => context.read<TopUpBloc>().add(SelectCertificate(tariff)),
context.read<TopUpBloc>().add(SelectCertificate(tariff)),
child: Container( child: Container(
width: 140, width: 140,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected ? const Color(0xFF80FFC1) : Colors.white.withOpacity(0.1),
? const Color(0xFF80FFC1)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white24), border: Border.all(color: Colors.white24),
), ),
@@ -123,7 +148,7 @@ class TopUpScreen extends StatelessWidget {
); );
} }
Widget _buildPriceInfo(TopUpState state) { Widget _buildPriceInfo(TopUpLoaded state) {
if (state.selectedTariff == null) return const SizedBox.shrink(); if (state.selectedTariff == null) return const SizedBox.shrink();
return Center( return Center(
child: Column( child: Column(
@@ -153,13 +178,13 @@ class TopUpScreen extends StatelessWidget {
); );
} }
Widget _buildCardSelector(TopUpState state, BuildContext context) { Widget _buildCardSelector(TopUpLoaded state, BuildContext context) {
return state.selectedCard != null return state.selectedCard != null
? 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>(
@@ -167,10 +192,12 @@ class TopUpScreen extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider( builder: (innerContext) => BlocProvider(
create: (context) => create: (context) => PaymentMethodSheetBloc(
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()) getIt<GetPaymentCardsUsecase>(),
..add(PaymentMethodSheetStarted()), getIt<SetMainPaymentCardUsecase>(),
child: const PaymentMethodSheet(showBalance: false,), getIt<GetProfileUseCase>(),
)..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(showBalance: false),
), ),
); );
@@ -202,7 +229,6 @@ class TopUpScreen extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text( const Text(
'Способ оплаты', 'Способ оплаты',
style: TextStyle( style: TextStyle(
@@ -212,21 +238,9 @@ class TopUpScreen extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon( Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.6)),
Icons.arrow_forward_ios, Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.4)),
size: 12, Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.2)),
color: Colors.white.withOpacity(0.6),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white.withOpacity(0.4),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white.withOpacity(0.2),
),
], ],
), ),
), ),
@@ -236,20 +250,19 @@ class TopUpScreen extends StatelessWidget {
); );
} }
Widget _buildAgreement(TopUpState state, BuildContext context) { Widget _buildAgreement(TopUpLoaded state, BuildContext context) {
return Row( return Row(
children: [ children: [
AppCheckbox( AppCheckbox(
value: state.isAgreed, value: state.isAgreed,
onChanged: (v) => onChanged: (v) => context.read<TopUpBloc>().add(ToggleAgreement(v ?? false)),
context.read<TopUpBloc>().add(ToggleAgreement(v ?? false)),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Flexible( const Flexible(
child: Text.rich( child: Text.rich(
TextSpan( TextSpan(
text: 'Я принимаю условия покупки бонусного пакета, при котором денежные средства не подлежат возврату... ', text: 'Я принимаю условия покупки бонусного пакека, при котором денежные средства не подлежат возврату... ',
style: const TextStyle( style: TextStyle(
color: AppColors.white70, color: AppColors.white70,
fontSize: 12, fontSize: 12,
), ),
@@ -260,10 +273,10 @@ class TopUpScreen extends StatelessWidget {
); );
} }
Widget _buildPayButton(TopUpState state) { Widget _buildPayButton(TopUpLoaded 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,
@@ -271,5 +284,4 @@ class TopUpScreen extends StatelessWidget {
fontSize: 16, fontSize: 16,
); );
} }
} }

View File

@@ -19,7 +19,7 @@ class ScooterState {
final String? address; final String? address;
final String? errorMessage; final String? errorMessage;
final String phoneNumber; final String phoneNumber;
final int balance; final double balance;
final UserCheckFlags flags; final UserCheckFlags flags;
final ClientNotification? lastNotification; final ClientNotification? lastNotification;
@@ -52,7 +52,7 @@ class ScooterState {
String? address, String? address,
String? errorMessage, String? errorMessage,
String? phoneNumber, String? phoneNumber,
int? balance, double? balance,
UserCheckFlags? flags, UserCheckFlags? flags,
ClientNotification? lastNotification, ClientNotification? lastNotification,
}) { }) {

View File

@@ -1,27 +1,43 @@
import '../../domain/entities/client_notification.dart'; import '../../domain/entities/client_notification.dart';
enum NotificationsStatus { initial, loading, success, failure } enum NotificationsStatus { initial, loading, loadingMore, success, failure }
class NotificationsState { class NotificationsState {
final NotificationsStatus status; final NotificationsStatus status;
final List<ClientNotification> notifications; final List<ClientNotification> notifications;
final String? errorMessage; final String? errorMessage;
final int currentPage;
final int totalPages;
final bool hasMore;
final bool isLoadingMore;
const NotificationsState({ const NotificationsState({
this.status = NotificationsStatus.initial, this.status = NotificationsStatus.initial,
this.notifications = const [], this.notifications = const [],
this.errorMessage, this.errorMessage,
this.currentPage = 1,
this.totalPages = 1,
this.hasMore = true,
this.isLoadingMore = false,
}); });
NotificationsState copyWith({ NotificationsState copyWith({
NotificationsStatus? status, NotificationsStatus? status,
List<ClientNotification>? notifications, List<ClientNotification>? notifications,
String? errorMessage, String? errorMessage,
int? currentPage,
int? totalPages,
bool? hasMore,
bool? isLoadingMore,
}) { }) {
return NotificationsState( return NotificationsState(
status: status ?? this.status, status: status ?? this.status,
notifications: notifications ?? this.notifications, notifications: notifications ?? this.notifications,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
currentPage: currentPage ?? this.currentPage,
totalPages: totalPages ?? this.totalPages,
hasMore: hasMore ?? this.hasMore,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
); );
} }
} }

View File

@@ -11,7 +11,7 @@ class PaymentConfirmState {
final bool paymentCompleted; final bool paymentCompleted;
final ScooterOrder? order; final ScooterOrder? order;
final bool useBalance; final bool useBalance;
final int userBalance; final double userBalance;
const PaymentConfirmState({ const PaymentConfirmState({
this.status = PaymentConfirmStatus.initial, this.status = PaymentConfirmStatus.initial,
@@ -30,7 +30,7 @@ class PaymentConfirmState {
bool? paymentCompleted, bool? paymentCompleted,
ScooterOrder? order, ScooterOrder? order,
bool? useBalance, bool? useBalance,
int? userBalance, double? userBalance,
}) { }) {
return PaymentConfirmState( return PaymentConfirmState(
status: status ?? this.status, status: status ?? this.status,

View File

@@ -11,7 +11,7 @@ class PaymentMethodSheetState {
PaymentMethodSheetState({ PaymentMethodSheetState({
this.status = PaymentMethodSheetStatus.initial, this.status = PaymentMethodSheetStatus.initial,
this.cards = const [], this.cards = const [],
this.balance = 0.0, this.balance = 0,
this.errorMessage, this.errorMessage,
}); });

View File

@@ -5,7 +5,7 @@ enum PaymentMethodsStatus { initial, loading, success, failure }
class PaymentMethodsState { class PaymentMethodsState {
final PaymentMethodsStatus status; final PaymentMethodsStatus status;
final List<PaymentCard> cards; final List<PaymentCard> cards;
final int balance; final double balance;
final String? errorMessage; final String? errorMessage;
final bool isDeleting; final bool isDeleting;
final bool isSettingMain; final bool isSettingMain;
@@ -22,7 +22,7 @@ class PaymentMethodsState {
PaymentMethodsState copyWith({ PaymentMethodsState copyWith({
PaymentMethodsStatus? status, PaymentMethodsStatus? status,
List<PaymentCard>? cards, List<PaymentCard>? cards,
int? balance, double? balance,
String? errorMessage, String? errorMessage,
bool? isDeleting, bool? isDeleting,
bool? isSettingMain, bool? isSettingMain,

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,9 +42,10 @@ 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

@@ -10,7 +10,7 @@ class TariffSheetState {
final String? errorMessage; final String? errorMessage;
final PaymentCard? selectedCard; final PaymentCard? selectedCard;
final bool isInsurance; final bool isInsurance;
final int userBalance; final double userBalance;
final bool useBalance ; final bool useBalance ;
TariffSheetState({ TariffSheetState({
@@ -29,7 +29,7 @@ class TariffSheetState {
PaymentCard? selectedCard, PaymentCard? selectedCard,
String? errorMessage, String? errorMessage,
bool? isInsurance, bool? isInsurance,
int? userBalance, double? userBalance,
bool? useBalance, bool? useBalance,
}) => TariffSheetState( }) => TariffSheetState(

View File

@@ -1,40 +1,55 @@
import 'package:be_happy/domain/entities/certificate.dart'; import 'package:be_happy/domain/entities/certificate.dart';
import 'package:be_happy/domain/entities/payment_card.dart'; import 'package:be_happy/domain/entities/payment_card.dart';
import '../../domain/entities/top_up_tariff.dart'; sealed class TopUpState {
const TopUpState();
}
class TopUpState { class TopUpLoading extends TopUpState {
const TopUpLoading();
}
class TopUpLoaded extends TopUpState {
final List<Certificate> certificates; final List<Certificate> certificates;
final List<PaymentCard> cards; final List<PaymentCard> cards;
final Certificate? selectedTariff; final Certificate? selectedTariff;
final PaymentCard? selectedCard; final PaymentCard? selectedCard;
final bool isLoading;
final bool isAgreed; final bool isAgreed;
final bool isProcessingPayment;
TopUpState({ const TopUpLoaded({
this.certificates = const [], this.certificates = const [],
this.cards = const [], this.cards = const [],
this.selectedTariff, this.selectedTariff,
this.selectedCard, this.selectedCard,
this.isLoading = false,
this.isAgreed = false, this.isAgreed = false,
this.isProcessingPayment = false,
}); });
TopUpState copyWith({ TopUpLoaded copyWith({
List<Certificate>? certificates, List<Certificate>? certificates,
List<PaymentCard>? cards, List<PaymentCard>? cards,
Certificate? selectedTariff, Certificate? selectedTariff,
PaymentCard? selectedCard, PaymentCard? selectedCard,
bool? isLoading,
bool? isAgreed, bool? isAgreed,
bool? isProcessingPayment,
}) { }) {
return TopUpState( return TopUpLoaded(
certificates: certificates ?? this.certificates, certificates: certificates ?? this.certificates,
cards: cards ?? this.cards, cards: cards ?? this.cards,
selectedTariff: selectedTariff ?? this.selectedTariff, selectedTariff: selectedTariff ?? this.selectedTariff,
selectedCard: selectedCard ?? this.selectedCard, selectedCard: selectedCard ?? this.selectedCard,
isLoading: isLoading ?? this.isLoading,
isAgreed: isAgreed ?? this.isAgreed, isAgreed: isAgreed ?? this.isAgreed,
isProcessingPayment: isProcessingPayment ?? this.isProcessingPayment,
); );
} }
}
class TopUpPaymentSuccess extends TopUpState {
const TopUpPaymentSuccess();
}
class TopUpPaymentFailure extends TopUpState {
final String errorMessage;
const TopUpPaymentFailure(this.errorMessage);
} }

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

@@ -8,7 +8,11 @@ class PhoneAuthBloc extends Bloc<PhoneAuthEvent, PhoneAuthState> {
PhoneAuthBloc(this._loginUseCase) : super(PhoneAuthState.initial()) { PhoneAuthBloc(this._loginUseCase) : super(PhoneAuthState.initial()) {
on<PhoneChanged>((event, emit) { on<PhoneChanged>((event, emit) {
emit(state.copyWith(phone: event.phone)); emit(state.copyWith(
phone: event.phone,
isSuccess: false,
error: null,
));
}); });
on<IsAdultChanged>((event, emit) { on<IsAdultChanged>((event, emit) {

View File

@@ -264,9 +264,10 @@ 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();
splashBloc.add(AuthCheckRequested()); stopNotificationStream();
splashBloc.add(AuthLogoutRequested());
} }
FutureOr<void> _onCheckUser( FutureOr<void> _onCheckUser(

View File

@@ -9,26 +9,53 @@ class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
NotificationsBloc(this._getNotificationsUsecase) : super(const NotificationsState()) { NotificationsBloc(this._getNotificationsUsecase) : super(const NotificationsState()) {
on<NotificationsFetchRequested>(_onFetchRequested); on<NotificationsFetchRequested>(_onFetchRequested);
on<NotificationsLoadMoreRequested>(_onLoadMoreRequested);
} }
Future<void> _onFetchRequested( Future<void> _onFetchRequested(
NotificationsFetchRequested event, NotificationsFetchRequested event,
Emitter<NotificationsState> emit, Emitter<NotificationsState> emit,
) async { ) async {
emit(state.copyWith(status: NotificationsStatus.loading)); if (event.page == 1) {
emit(state.copyWith(status: NotificationsStatus.loading));
}
try { try {
final notifications = await _getNotificationsUsecase(); final result = await _getNotificationsUsecase(
page: event.page,
pageSize: 100,
);
final newNotifications = event.page == 1
? result.notifications
: [...state.notifications, ...result.notifications];
emit(state.copyWith( emit(state.copyWith(
status: NotificationsStatus.success, status: NotificationsStatus.success,
notifications: notifications, notifications: newNotifications,
currentPage: result.pagination.currentPage,
totalPages: result.pagination.lastPage,
hasMore: result.pagination.currentPage < result.pagination.lastPage,
isLoadingMore: false,
)); ));
} catch (e) { } catch (e) {
dev.log('NotificationsBloc: Ошибка загрузки: $e'); dev.log('NotificationsBloc: Ошибка загрузки: $e');
emit(state.copyWith( emit(state.copyWith(
status: NotificationsStatus.failure, status: event.page == 1 ? NotificationsStatus.failure : NotificationsStatus.success,
errorMessage: e.toString(), errorMessage: event.page == 1 ? e.toString() : null,
isLoadingMore: false,
)); ));
} }
} }
Future<void> _onLoadMoreRequested(
NotificationsLoadMoreRequested event,
Emitter<NotificationsState> emit,
) async {
if (!state.hasMore || state.isLoadingMore) return;
emit(state.copyWith(isLoadingMore: true));
final nextPage = state.currentPage + 1;
add(NotificationsFetchRequested(page: nextPage));
}
} }

View File

@@ -3,13 +3,17 @@ 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/get_profile_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 GetProfileUseCase _getProfileUseCase;
final SetMainPaymentCardUsecase _setMainPaymentCardUsecase;
PaymentMethodSheetBloc(this._getPaymentCardsUsecase) PaymentMethodSheetBloc(this._getPaymentCardsUsecase, this._setMainPaymentCardUsecase, this._getProfileUseCase)
: super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) { : super(PaymentMethodSheetState(status: PaymentMethodSheetStatus.initial)) {
on<PaymentMethodSheetStarted>(_onStarted); on<PaymentMethodSheetStarted>(_onStarted);
} }
@@ -22,7 +26,59 @@ class PaymentMethodSheetBloc extends Bloc<PaymentMethodSheetEvent, PaymentMethod
try { try {
final result = await _getPaymentCardsUsecase(); final result = await _getPaymentCardsUsecase();
final profile = await _getProfileUseCase();
if (result is Success<List<PaymentCard>>) {
emit(state.copyWith(
status: PaymentMethodSheetStatus.success,
balance: profile.balance,
cards: result.data ?? [],
));
} else {
emit(state.copyWith(
status: PaymentMethodSheetStatus.failure,
errorMessage: 'Failed to load payment cards',
));
}
} catch (e) {
emit(state.copyWith(
status: PaymentMethodSheetStatus.failure,
errorMessage: e.toString(),
));
}
}
Future<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>>) { if (result is Success<List<PaymentCard>>) {
emit(state.copyWith( emit(state.copyWith(
status: PaymentMethodSheetStatus.success, status: PaymentMethodSheetStatus.success,

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(
@@ -39,6 +46,8 @@ class PaymentMethodsBloc extends Bloc<PaymentMethodsEvent, PaymentMethodsState>
if (result is Success<List<PaymentCard>>) { if (result is Success<List<PaymentCard>>) {
print("BALANCE: ${profile.balance} BYN");
emit(state.copyWith( emit(state.copyWith(
status: PaymentMethodsStatus.success, status: PaymentMethodsStatus.success,
cards: result.data ?? [], cards: result.data ?? [],
@@ -136,4 +145,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

@@ -48,7 +48,7 @@ class ReservedRideBloc extends Bloc<ReservedRideEvent, ReservedRideState> {
final result = await _cancelRideUsecase(event.orderId); final result = await _cancelRideUsecase(event.orderId);
if (result is Success<ScooterOrder>) { if (result is Success) {
emit(state.copyWith( emit(state.copyWith(
status: ReservedRideStatus.success, status: ReservedRideStatus.success,
rideCancelled: true, rideCancelled: true,

View File

@@ -40,7 +40,6 @@ class SendPhotoBloc extends Bloc<SendPhotoEvent, SendPhotoState> {
switch (result) { switch (result) {
case Success<List<int>>(): case Success<List<int>>():
// ✅ Защита: если data null или пустой — не вызываем finish
if (result.data == null || result.data!.isEmpty) { if (result.data == null || result.data!.isEmpty) {
emit(state.copyWith( emit(state.copyWith(
status: SendPhotoStatus.failure, status: SendPhotoStatus.failure,
@@ -49,20 +48,19 @@ class SendPhotoBloc extends Bloc<SendPhotoEvent, SendPhotoState> {
return; return;
} }
// ✅ Передаём два параметра в UseCase
final finishResult = await _finishRideUsecase( final finishResult = await _finishRideUsecase(
event.orderId, event.orderId,
result.data! // ✅ ! т.к. проверили выше result.data!
); );
switch (finishResult) { switch (finishResult) {
case Success<ScooterOrder>(): case Success():
emit(state.copyWith( emit(state.copyWith(
status: SendPhotoStatus.success, status: SendPhotoStatus.success,
recievedPhotoIds: result.data, recievedPhotoIds: result.data,
)); ));
case Failure(): case Failure():
print("❌ FINISH ERROR: ${finishResult.failure.message}"); print("FINISH ERROR: ${finishResult.failure.message}");
emit(state.copyWith( emit(state.copyWith(
status: SendPhotoStatus.failure, status: SendPhotoStatus.failure,
errorMessage: 'Ошибка завершения поездки', errorMessage: 'Ошибка завершения поездки',

View File

@@ -16,6 +16,9 @@ class SplashBloc extends Bloc<SplashEvent, SplashState> {
on<PinVerificationSuccess>((event, emit) { on<PinVerificationSuccess>((event, emit) {
emit(AuthPinVerified()); emit(AuthPinVerified());
}); });
on<AuthLogoutRequested>((event, emit) {
emit(AuthUnauthenticated());
});
} }
Future<void> _onAuthCheckRequested( Future<void> _onAuthCheckRequested(

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

@@ -18,11 +18,11 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
required this.getCertificatesUsecase, required this.getCertificatesUsecase,
required this.purchaseCertificateUsecase, required this.purchaseCertificateUsecase,
required this.getUserCards, required this.getUserCards,
}) : super(TopUpState(isLoading: true)) { }) : super(const TopUpLoading()) {
on<LoadTopUpData>((event, emit) async {
emit(state.copyWith(isLoading: true)); on<LoadTopUpData>((event, emit) async {
emit(const TopUpLoading());
// Запускаем оба запроса параллельно
final results = await Future.wait([ final results = await Future.wait([
getCertificatesUsecase(), getCertificatesUsecase(),
getUserCards(), getUserCards(),
@@ -34,48 +34,68 @@ class TopUpBloc extends Bloc<TopUpEvent, TopUpState> {
switch ((certResult, cardResult)) { switch ((certResult, cardResult)) {
case (Success(data: final certs), Success(data: final cards)): case (Success(data: final certs), Success(data: final cards)):
emit( emit(
state.copyWith( TopUpLoaded(
certificates: certs, certificates: certs ?? [],
cards: cards, cards: cards ?? [],
selectedTariff: certs!.length > 1 selectedTariff: certs != null && certs.length > 1
? certs[1] ? certs[1]
: (certs.isNotEmpty ? certs.first : null), : (certs != null && certs.isNotEmpty ? certs.first : null),
selectedCard: cards == null || cards.isEmpty
selectedCard: cards!.isEmpty
? null ? null
: cards.firstWhere( : cards.firstWhere(
(c) => c.isMain, (c) => c.isMain,
orElse: () => cards.first, orElse: () => cards.first,
), ),
isLoading: false,
), ),
); );
default:
case ( emit(const TopUpPaymentFailure('Не удалось загрузить данные'));
Success<List<Certificate>>(data: null), }
Failure<List<PaymentCard>>(), });
):
// TODO: Handle this case. on<SelectCertificate>((event, emit) {
throw UnimplementedError(); if (state is TopUpLoaded) {
case ( final currentState = state as TopUpLoaded;
Success<List<Certificate>>(data: [...]), emit(currentState.copyWith(selectedTariff: event.certificate));
Failure<List<PaymentCard>>(), }
): });
// TODO: Handle this case.
throw UnimplementedError(); on<SelectCard>((event, emit) {
case (Failure<List<Certificate>>(), _): if (state is TopUpLoaded) {
// TODO: Handle this case. final currentState = state as TopUpLoaded;
throw UnimplementedError(); emit(currentState.copyWith(selectedCard: event.card));
}
});
on<ToggleAgreement>((event, emit) {
if (state is TopUpLoaded) {
final currentState = state as TopUpLoaded;
emit(currentState.copyWith(isAgreed: event.value));
}
});
on<TopUpButtonPressed>((event, emit) async {
if (state is TopUpLoaded) {
final currentState = state as TopUpLoaded;
if (currentState.isAgreed &&
currentState.selectedTariff != null &&
currentState.selectedCard != null) {
emit(currentState.copyWith(isProcessingPayment: true));
final result = await purchaseCertificateUsecase(
certificateId: currentState.selectedTariff!.id,
cardId: currentState.selectedCard!.id,
);
if (result is Success) {
emit(const TopUpPaymentSuccess());
} else {
emit(const TopUpPaymentFailure('Ошибка при оплате'));
}
}
} }
}); });
on<SelectCertificate>(
(event, emit) => emit(state.copyWith(selectedTariff: event.certificate)),
);
on<SelectCard>(
(event, emit) => emit(state.copyWith(selectedCard: event.card)),
);
on<ToggleAgreement>(
(event, emit) => emit(state.copyWith(isAgreed: event.value)),
);
} }
} }

View File

@@ -1,19 +1,35 @@
import 'dart:async'; import 'dart:async';
import 'package:be_happy/core/failures.dart';
import 'package:be_happy/core/result.dart';
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecase/verify_code_usecase.dart';
import '../event/spalsh_event.dart';
import '../event/verify_code_event.dart';
import '../state/verify_code_state.dart';
import 'dart:async';
import 'package:be_happy/core/failures.dart'; import 'package:be_happy/core/failures.dart';
import 'package:be_happy/core/result.dart'; import 'package:be_happy/core/result.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecase/verify_code_usecase.dart'; import '../../domain/usecase/verify_code_usecase.dart';
import '../../domain/usecase/login_usecase.dart'; // 🔹 Импортируем LoginUseCase
import '../event/verify_code_event.dart'; import '../event/verify_code_event.dart';
import '../state/verify_code_state.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;
final SplashBloc _splashBloc;
Timer? _timer; Timer? _timer;
VerifyCodeBloc(this._verifyCodeUseCase) VerifyCodeBloc(this._verifyCodeUseCase, this._loginUseCase, this._splashBloc)
: 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,56 +40,91 @@ 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));
_startTimer();
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); on<VerifyCodeSubmitted>(_onVerifyCodeSubmitted);
} }
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));
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() => (() {
isSubmitting: false, _timer?.cancel();
isSuccess: true,
), _splashBloc.add(AuthCheckRequested());
return state.copyWith(
isSubmitting: false,
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() => (() {
isSubmitting: false, _timer?.cancel();
isBlocked: true, return state.copyWith(
error: 'Вы заблокированы за слишком частые попытки', isSubmitting: false,
), isBlocked: true,
error: 'Вы заблокированы за слишком частые попытки',
);
})(),
_ => state.copyWith( _ => state.copyWith(
isSubmitting: false, isSubmitting: false,
error: 'Произошла ошибка. Попробуйте позже', error: 'Произошла ошибка. Попробуйте позже',
@@ -89,4 +140,4 @@ class VerifyCodeBloc extends Bloc<VerifyCodeEvent, VerifyCodeState> {
_timer?.cancel(); _timer?.cancel();
return super.close(); return super.close();
} }
} }

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