3 Commits

102 changed files with 2040 additions and 1209 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/icons/timer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/news_def.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
assets/splash_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/splash_map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
lib.rar

Binary file not shown.

View File

@@ -20,6 +20,7 @@ import 'package:path/path.dart';
import 'package:flutter_client_sse/constants/sse_request_type_enum.dart'; import 'package:flutter_client_sse/constants/sse_request_type_enum.dart';
import 'package:flutter_client_sse/flutter_client_sse.dart'; import 'package:flutter_client_sse/flutter_client_sse.dart';
import '../../domain/entities/client_subscription.dart';
import '../../domain/entities/point.dart'; import '../../domain/entities/point.dart';
import '../../domain/entities/user_profile.dart'; import '../../domain/entities/user_profile.dart';
import '../../domain/entities/payment_card.dart'; import '../../domain/entities/payment_card.dart';
@@ -137,7 +138,8 @@ class ApiService {
if (avatarId != null && profileData["avatar"] != null) { if (avatarId != null && profileData["avatar"] != null) {
final String? avatarPath = profileData["avatar"]["path"]; final String? avatarPath = profileData["avatar"]["path"];
if (avatarPath != null && avatarPath.isNotEmpty) { if (avatarPath != null && avatarPath.isNotEmpty) {
avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString(); } avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString();
}
} }
dynamic balanceRaw = profileData["balance"]; dynamic balanceRaw = profileData["balance"];
@@ -394,19 +396,16 @@ class ApiService {
final url = "$baseUrl/scooter/$title/code"; final url = "$baseUrl/scooter/$title/code";
try { try {
final response = await _dio.get( final response = await _dio.get(url, options: await _getAuthOptions());
url,
options: await _getAuthOptions(),
);
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return Scooter.fromJson(response.data); return Scooter.fromJson(response.data);
} }
if (response.statusCode == 404) { if (response.statusCode == 404) {
throw ScooterNotFoundException(message: "Самокат не найден"); throw ScooterNotFoundException(message: "Самокат не найден");
} }
return null; return null;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 401) throw UnauthorizedException(); if (e.response?.statusCode == 401) throw UnauthorizedException();
@@ -463,7 +462,7 @@ class ApiService {
} }
} }
Future<List<Subscription>> getClientSubscriptions() async { Future<List<ClientSubscription>> getClientSubscriptions() async {
const url = "$baseUrl/scootersubscription/client"; const url = "$baseUrl/scootersubscription/client";
try { try {
@@ -474,14 +473,7 @@ class ApiService {
final List<dynamic> items = responseData['data'] ?? []; final List<dynamic> items = responseData['data'] ?? [];
return items.map((item) { return items.map((item) {
final Map<String, dynamic> subscriptionMap = return ClientSubscription.fromJson(item);
Map<String, dynamic>.from(item['subscription'] ?? {});
if (item['expiredAt'] != null) {
subscriptionMap['activeTo'] = item['expiredAt'];
}
return Subscription.fromJson(subscriptionMap);
}).toList(); }).toList();
} }
return []; return [];
@@ -547,7 +539,7 @@ class ApiService {
} }
} }
Future<int> addPaymentCard({ Future<void> addPaymentCard({
required String cardNumber, required String cardNumber,
required String cardHolder, required String cardHolder,
required int expirationMonth, required int expirationMonth,
@@ -571,8 +563,9 @@ class ApiService {
); );
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return response.data['id'] as int; return;
} }
throw AuthException('Непредвиденный статус: ${response.statusCode}', 0); throw AuthException('Непредвиденный статус: ${response.statusCode}', 0);
} on DioException catch (e) { } on DioException catch (e) {
final data = e.response?.data; final data = e.response?.data;
@@ -674,7 +667,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString(); final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) { if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для начала поездки."); throw WrongZoneException(
message: "Некорректная зона для начала поездки.",
);
} }
} }
@@ -705,7 +700,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString(); final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) { if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для начала поездки."); throw WrongZoneException(
message: "Некорректная зона для начала поездки.",
);
} }
} }
@@ -791,7 +788,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString(); final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) { if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для завершения поездки."); throw WrongZoneException(
message: "Некорректная зона для завершения поездки.",
);
} }
} }
@@ -803,7 +802,7 @@ class ApiService {
} }
} }
Future<ScooterOrder?> payRide(int orderId) async { Future<void> payRide(int orderId) async {
try { try {
final response = await _dio.put( final response = await _dio.put(
"$baseUrl/scooterorder/$orderId/pay", "$baseUrl/scooterorder/$orderId/pay",
@@ -811,12 +810,12 @@ class ApiService {
); );
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data); // return ScooterOrder.fromJson(response.data);
return;
} }
return null;
} on DioException catch (e) { } on DioException catch (e) {
_handleDioError(e); _handleDioError(e);
return null; return;
} }
} }
@@ -878,7 +877,7 @@ class ApiService {
} }
} }
Future<ScooterOrder?> payScooterOrderWithPhotos({ Future<void> payScooterOrderWithPhotos({
required int orderId, required int orderId,
required int? cardId, required int? cardId,
required bool isBalance, required bool isBalance,
@@ -891,12 +890,10 @@ class ApiService {
); );
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data); return;
} }
return null;
} on DioException catch (e) { } on DioException catch (e) {
_handleDioError(e); _handleDioError(e);
return null;
} }
} }
@@ -918,7 +915,6 @@ class ApiService {
} }
} }
Future<List<Point>> getScooterOrderRouteHistory({required int id}) async { Future<List<Point>> getScooterOrderRouteHistory({required int id}) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
@@ -930,13 +926,19 @@ class ApiService {
final String routeString = response.data['route'] ?? '[]'; final String routeString = response.data['route'] ?? '[]';
final List<dynamic> routeList = json.decode(routeString); final List<dynamic> routeList = json.decode(routeString);
return routeList.map((item) => Point( return routeList
(item[1] as num).toDouble(), .map(
(item[0] as num).toDouble(), (item) => Point(
)).toList(); (item[1] as num).toDouble(),
(item[0] as num).toDouble(),
),
)
.toList();
} }
throw RouteHistoryNotFoundException(message: "История маршрута не найдена"); throw RouteHistoryNotFoundException(
message: "История маршрута не найдена",
);
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 401) throw UnauthorizedException(); if (e.response?.statusCode == 401) throw UnauthorizedException();
if (e.response?.statusCode == 403) throw AuthBlockException(); if (e.response?.statusCode == 403) throw AuthBlockException();
@@ -1038,6 +1040,57 @@ class ApiService {
return controller.stream; return controller.stream;
} }
Future<List<Map<String, dynamic>>> getNotifications() async {
final url = Uri.parse('$baseUrl/notification/client');
final accessToken = await _securityService.getAccessToken();
if (accessToken == null) {
print("APISERVICE Error: Access token is null.");
throw UnauthorizedException();
}
print("GET NOTIFICATIONS REQUEST:");
print("URL: $url");
final response = await http.get(
url,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer $accessToken",
},
);
print("GET NOTIFICATIONS RESPONSE:");
print("STATUS: ${response.statusCode}");
print("BODY: ${response.body}");
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
// ✅ Проверяем, является ли ответ массивом или объектом с data[]
if (data is List) {
return data.cast<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}');
}
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw AuthBlockException();
}
throw Exception('Ошибка сервера: ${response.statusCode}');
}
Future<List<Certificate>> getCertificates() async { Future<List<Certificate>> getCertificates() async {
try { try {
final response = await _dio.get( final response = await _dio.get(

View File

@@ -33,4 +33,22 @@ class NotificationRepositoryImpl implements NotificationRepository {
void closeStream() { void closeStream() {
// соединение закрывается автоматически при отписке от stream // соединение закрывается автоматически при отписке от stream
} }
@override
Future<List<ClientNotification>> getNotifications() async {
try {
final List<Map<String, dynamic>> data = await _apiService.getNotifications();
final notifications = data.map((json) {
final dto = ClientNotificationDto.fromJson(json);
return dto.toEntity();
}).toList();
// dev.log('NotificationRepository: Загружено ${notifications.length} уведомлений');
return notifications;
} catch (e, stackTrace) {
// dev.log('NotificationRepository: Ошибка: $e', stackTrace: stackTrace);
throw Exception('Не удалось загрузить уведомления: $e');
}
}
} }

View File

@@ -40,7 +40,7 @@ class PaymentRepositoryImpl implements PaymentRepository {
required String cvv, required String cvv,
}) async { }) async {
try { try {
final cardId = await apiService.addPaymentCard( await apiService.addPaymentCard(
cardNumber: cardNumber, cardNumber: cardNumber,
cardHolder: cardHolder, cardHolder: cardHolder,
expirationMonth: int.parse(expiryMonth), expirationMonth: int.parse(expiryMonth),
@@ -48,8 +48,7 @@ class PaymentRepositoryImpl implements PaymentRepository {
cvv: cvv, cvv: cvv,
); );
// Сохраняем полный номер карты локально // await securityService.saveCardFullNumber(cardId, cardNumber);
await securityService.saveCardFullNumber(cardId, cardNumber);
return Success(null); return Success(null);
} on AuthException catch (e) { } on AuthException catch (e) {

View File

@@ -10,6 +10,7 @@ import 'package:be_happy/domain/repositories/scooter_repository.dart';
import '../../core/failures.dart'; import '../../core/failures.dart';
import '../../core/result.dart'; import '../../core/result.dart';
import '../../domain/entities/active_scooter_order.dart'; import '../../domain/entities/active_scooter_order.dart';
import '../../domain/entities/client_subscription.dart';
import '../../domain/entities/scooter.dart'; import '../../domain/entities/scooter.dart';
import '../../domain/entities/tariff.dart'; import '../../domain/entities/tariff.dart';
import '../../domain/entities/subscription.dart'; import '../../domain/entities/subscription.dart';
@@ -127,8 +128,8 @@ class ScooterRepositoryImpl extends ScooterRepository {
} }
@override @override
Future<Result<List<Subscription>>> getClientSubscriptions() async { Future<Result<List<ClientSubscription>>> getClientSubscriptions() async {
late final Result<List<Subscription>> result; late final Result<List<ClientSubscription>> result;
try { try {
final subscriptions = await _apiService.getClientSubscriptions(); final subscriptions = await _apiService.getClientSubscriptions();
result = Success(subscriptions); result = Success(subscriptions);
@@ -268,15 +269,12 @@ class ScooterRepositoryImpl extends ScooterRepository {
} }
@override @override
Future<Result<ScooterOrder>> payRide(int orderId) async { Future<Result<void>> payRide(int orderId) async {
late final Result<ScooterOrder> result; late final Result<void> result;
try { try {
final order = await _apiService.payRide(orderId); await _apiService.payRide(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) {
@@ -335,23 +333,19 @@ class ScooterRepositoryImpl extends ScooterRepository {
} }
@override @override
Future<Result<ScooterOrder>> payScooterOrderWithPhotos({ Future<Result<void>> payScooterOrderWithPhotos({
required int orderId, required int orderId,
required int? cardId, required int? cardId,
required bool isBalance, required bool isBalance,
}) async { }) async {
late final Result<ScooterOrder> result; late final Result<void> result;
try { try {
final order = await _apiService.payScooterOrderWithPhotos( final order = await _apiService.payScooterOrderWithPhotos(
orderId: orderId, orderId: orderId,
cardId: cardId, cardId: cardId,
isBalance: isBalance, isBalance: isBalance,
); );
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) {

View File

@@ -92,6 +92,7 @@ import '../domain/service/device_info_service.dart';
import '../domain/usecase/activate_subscription_usecase.dart'; import '../domain/usecase/activate_subscription_usecase.dart';
import '../domain/usecase/get_client_subscriptions_usecase.dart'; import '../domain/usecase/get_client_subscriptions_usecase.dart';
import '../domain/usecase/get_news_by_id_usecase.dart'; import '../domain/usecase/get_news_by_id_usecase.dart';
import '../domain/usecase/get_notifications_usecase.dart';
import '../domain/usecase/get_scooter_by_title_usecase.dart'; import '../domain/usecase/get_scooter_by_title_usecase.dart';
import '../domain/usecase/get_scooter_order_history_usecase.dart'; import '../domain/usecase/get_scooter_order_history_usecase.dart';
import '../domain/usecase/remove_payment_card_usecase.dart'; import '../domain/usecase/remove_payment_card_usecase.dart';
@@ -100,6 +101,7 @@ import '../presentation/viewmodel/auth_bloc.dart';
import '../presentation/viewmodel/edit_profile_bloc.dart'; import '../presentation/viewmodel/edit_profile_bloc.dart';
import '../presentation/viewmodel/map_bloc.dart'; import '../presentation/viewmodel/map_bloc.dart';
import '../presentation/viewmodel/news_bloc.dart'; import '../presentation/viewmodel/news_bloc.dart';
import '../presentation/viewmodel/notifications_bloc.dart';
import '../presentation/viewmodel/order_history_bloc.dart'; import '../presentation/viewmodel/order_history_bloc.dart';
import '../presentation/viewmodel/scooter_detail_modal_bloc.dart'; import '../presentation/viewmodel/scooter_detail_modal_bloc.dart';
import '../presentation/viewmodel/subscription_list_bloc.dart'; import '../presentation/viewmodel/subscription_list_bloc.dart';
@@ -110,7 +112,7 @@ final getIt = GetIt.instance;
Future<void> setupDependencies() async { Future<void> setupDependencies() async {
final sharedPreferences = await SharedPreferences.getInstance(); final sharedPreferences = await SharedPreferences.getInstance();
final dio = Dio(); final dio = Dio();
dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true)); dio.interceptors.add(LogInterceptor(/*requestHeader: false, responseHeader:false, */responseBody: true, requestBody: true));
dio.interceptors.add(AuthInterceptor()); dio.interceptors.add(AuthInterceptor());
// HTTP // HTTP
getIt.registerSingleton<http.Client>(http.Client()); getIt.registerSingleton<http.Client>(http.Client());
@@ -286,6 +288,13 @@ Future<void> setupDependencies() async {
getIt.registerSingleton<GetScooterByTitleUsecase>( getIt.registerSingleton<GetScooterByTitleUsecase>(
GetScooterByTitleUsecase(getIt()), GetScooterByTitleUsecase(getIt()),
); );
getIt.registerSingleton<GetNotificationsUsecase>(
GetNotificationsUsecase(getIt<NotificationRepository>()),
);
getIt.registerFactory<NotificationsBloc>(
() => NotificationsBloc(getIt<GetNotificationsUsecase>()),
);
// Blocs // Blocs
getIt.registerLazySingleton<SplashBloc>(() => SplashBloc(getIt())); getIt.registerLazySingleton<SplashBloc>(() => SplashBloc(getIt()));
@@ -310,6 +319,7 @@ Future<void> setupDependencies() async {
getIt(), getIt(),
getIt(), getIt(),
getIt(), getIt(),
getIt(),
), ),
); );

View File

@@ -0,0 +1,24 @@
import 'package:be_happy/domain/entities/subscription.dart';
class ClientSubscription {
final int id;
final int subscriptionId;
final Subscription subscription;
final DateTime? expiredAt;
ClientSubscription({
required this.id,
required this.subscriptionId,
required this.subscription,
this.expiredAt,
});
factory ClientSubscription.fromJson(Map<String, dynamic> json) {
return ClientSubscription(
id: json['id'] ?? 0,
subscriptionId: json['subscriptionId'] ?? 0,
subscription: Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
expiredAt: json['expiredAt'] != null ? DateTime.parse(json['expiredAt']) : null,
);
}
}

View File

@@ -43,8 +43,9 @@ class Scooter {
); );
} }
@override @override
String toString() { String toString() {
return 'Scooter{id: $id, title: $title}'; return 'Scooter{id: $id, title: $title, status: $status, latitude: $latitude, longitude: $longitude, batteryLevel: $batteryLevel, isOnline: $isOnline}';
} }
} }

View File

@@ -3,7 +3,7 @@ import 'scooter.dart';
class ScooterOrder { class ScooterOrder {
final int id; final int id;
final int scooterId; final int scooterId;
final Scooter? scooter; final Scooter scooter;
final int? planId; final int? planId;
final ScooterPlan? plan; final ScooterPlan? plan;
final int clientId; final int clientId;
@@ -33,7 +33,7 @@ class ScooterOrder {
ScooterOrder({ ScooterOrder({
required this.id, required this.id,
required this.scooterId, required this.scooterId,
this.scooter, required this.scooter,
this.planId, this.planId,
this.plan, this.plan,
required this.clientId, required this.clientId,
@@ -65,7 +65,7 @@ class ScooterOrder {
return ScooterOrder( return ScooterOrder(
id: json['id'] ?? 0, id: json['id'] ?? 0,
scooterId: json['scooterId'] ?? 0, scooterId: json['scooterId'] ?? 0,
scooter: json['scooter'] != null ? Scooter.fromJson(json['scooter']) : null, scooter: Scooter.fromJson(json['scooter']),
planId: json['planId'], planId: json['planId'],
plan: json['plan'] != null ? ScooterPlan.fromJson(json['plan']) : null, plan: json['plan'] != null ? ScooterPlan.fromJson(json['plan']) : null,
clientId: json['clientId'] ?? 0, clientId: json['clientId'] ?? 0,

View File

@@ -8,6 +8,7 @@ class Subscription {
final String fullDescription; final String fullDescription;
final int planId; final int planId;
final bool isActive; final bool isActive;
final bool isCurrent;
final String currency; final String currency;
final DateTime? activeFrom; final DateTime? activeFrom;
final DateTime? activeTo; final DateTime? activeTo;
@@ -28,6 +29,7 @@ class Subscription {
this.activeTo, this.activeTo,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.isCurrent,
required this.options, required this.options,
}); });
@@ -48,6 +50,7 @@ class Subscription {
activeTo: json['activeTo'] != null ? DateTime.parse(json['activeTo']) : null, activeTo: json['activeTo'] != null ? DateTime.parse(json['activeTo']) : null,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : DateTime.now(), createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : DateTime.now(),
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : DateTime.now(), updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : DateTime.now(),
isCurrent: json['isCurrent'] ?? false,
options: optionsData.map((e) => SubscriptionPeriod.fromJson(e as Map<String, dynamic>)).toList(), options: optionsData.map((e) => SubscriptionPeriod.fromJson(e as Map<String, dynamic>)).toList(),
); );
} }

View File

@@ -9,4 +9,7 @@ abstract class NotificationRepository {
/// Закрывает SSE-соединение /// Закрывает SSE-соединение
void closeStream(); void closeStream();
/// получить список уведомлений
Future<List<ClientNotification>> getNotifications();
} }

View File

@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:be_happy/domain/entities/active_scooter_order.dart'; import 'package:be_happy/domain/entities/active_scooter_order.dart';
import '../../core/result.dart'; import '../../core/result.dart';
import '../entities/client_subscription.dart';
import '../entities/point.dart'; import '../entities/point.dart';
import '../entities/scooter.dart'; import '../entities/scooter.dart';
import '../entities/subscription.dart'; import '../entities/subscription.dart';
@@ -16,7 +17,7 @@ abstract class ScooterRepository {
Future<Result<List<Tariff>>> getAvailableTariffs(int scooterId); Future<Result<List<Tariff>>> getAvailableTariffs(int scooterId);
Future<Result<List<Subscription>>> getAvailableSubscriptions(); Future<Result<List<Subscription>>> getAvailableSubscriptions();
Future<Result<Subscription>> getSubscriptionById(int id); Future<Result<Subscription>> getSubscriptionById(int id);
Future<Result<List<Subscription>>> getClientSubscriptions(); Future<Result<List<ClientSubscription>>> getClientSubscriptions();
Future<Result<ScooterOrder>> bookScooter({ Future<Result<ScooterOrder>> bookScooter({
required int scooterId, required int scooterId,
required int planId, required int planId,
@@ -30,13 +31,13 @@ abstract class ScooterRepository {
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<ScooterOrder>> finishRide(int orderId, List<int> files);
Future<Result<ScooterOrder>> 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);
Future<Result<ActiveScooterOrder>> updateScooterOrderData({ Future<Result<ActiveScooterOrder>> updateScooterOrderData({
required int orderId, required int orderId,
}); });
Future<Result<ScooterOrder>> payScooterOrderWithPhotos({ Future<Result<void>> payScooterOrderWithPhotos({
required int orderId, required int orderId,
required int? cardId, required int? cardId,
required bool isBalance, required bool isBalance,

View File

@@ -1,6 +1,7 @@
import 'package:be_happy/core/result.dart'; import 'package:be_happy/core/result.dart';
import 'package:be_happy/domain/entities/scooter_order.dart'; import 'package:be_happy/domain/entities/scooter_order.dart';
import '../entities/client_subscription.dart';
import '../repositories/scooter_repository.dart'; import '../repositories/scooter_repository.dart';
@@ -14,7 +15,7 @@ class GetClientSubscriptionsUsecase {
GetClientSubscriptionsUsecase(this.repository); GetClientSubscriptionsUsecase(this.repository);
Future<Result<List<Subscription>>> call() { Future<Result<List<ClientSubscription>>> call() {
return repository.getClientSubscriptions(); return repository.getClientSubscriptions();
} }
} }

View File

@@ -0,0 +1,12 @@
import '../entities/client_notification.dart';
import '../repositories/notification_repository.dart';
class GetNotificationsUsecase {
final NotificationRepository repository;
GetNotificationsUsecase(this.repository);
Future<List<ClientNotification>> call() {
return repository.getNotifications();
}
}

View File

@@ -7,7 +7,7 @@ class PayRideUsecase {
PayRideUsecase(this.repository); PayRideUsecase(this.repository);
Future<Result<ScooterOrder>> call(int orderId, int? cardId, Future<Result<void>> call(int orderId, int? cardId,
bool isBalance) { bool isBalance) {
return repository.payScooterOrderWithPhotos(orderId: orderId, cardId: cardId, isBalance: isBalance); return repository.payScooterOrderWithPhotos(orderId: orderId, cardId: cardId, isBalance: isBalance);
} }

View File

@@ -7,7 +7,7 @@ class PayScooterOrderWithPhotosUsecase {
PayScooterOrderWithPhotosUsecase(this.repository); PayScooterOrderWithPhotosUsecase(this.repository);
Future<Result<ScooterOrder>> call({ Future<Result<void>> call({
required int orderId, required int orderId,
required int cardId, required int cardId,
required bool isBalance, required bool isBalance,

View File

@@ -23,9 +23,21 @@ import 'di/service_locator.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [SystemUiOverlay.top],
);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
);
await setupDependencies(); await setupDependencies();
runApp(const MyApp()); runApp(const MyApp());
} }
@@ -62,4 +74,4 @@ class MyApp extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -3,11 +3,8 @@ import 'package:flutter/material.dart';
class CancelBookingDialog extends StatelessWidget { class CancelBookingDialog extends StatelessWidget {
const CancelBookingDialog({super.key}); const CancelBookingDialog({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool result = false;
return Dialog( return Dialog(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 40), insetPadding: const EdgeInsets.symmetric(horizontal: 40),
@@ -42,6 +39,7 @@ class CancelBookingDialog extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// 🔹 Кнопка "Отменить" — ПЕРВАЯ, зелёная (градиент), возвращает true
Container( Container(
width: double.infinity, width: double.infinity,
height: 52, height: 52,
@@ -52,10 +50,7 @@ class CancelBookingDialog extends StatelessWidget {
), ),
), ),
child: ElevatedButton( child: ElevatedButton(
onPressed: () => { onPressed: () => Navigator.pop(context, true), // ✅ true = отменить
result = false,
Navigator.pop(context, result)
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
@@ -64,9 +59,9 @@ class CancelBookingDialog extends StatelessWidget {
), ),
), ),
child: const Text( child: const Text(
"Оставить", "Отменить",
style: TextStyle( style: TextStyle(
color: Color(0xFF1D273A), color: Color(0xFF1D273A), // тёмный текст на светлом градиенте
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -74,14 +69,13 @@ class CancelBookingDialog extends StatelessWidget {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 🔹 Кнопка "Оставить" — ВТОРАЯ, тёмная, возвращает false
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 52, height: 52,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () => Navigator.pop(context, false), // ❌ false = оставить
result = true;
Navigator.pop(context, result);
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0D1024), backgroundColor: const Color(0xFF0D1024),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -89,7 +83,7 @@ class CancelBookingDialog extends StatelessWidget {
), ),
), ),
child: const Text( child: const Text(
"Отменить", "Оставить",
style: TextStyle(color: Colors.white, fontSize: 16), style: TextStyle(color: Colors.white, fontSize: 16),
), ),
), ),
@@ -99,4 +93,4 @@ class CancelBookingDialog extends StatelessWidget {
), ),
); );
} }
} }

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
class FinishRideConfirmationDialog extends StatelessWidget {
const FinishRideConfirmationDialog({super.key});
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 40),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF2E3253).withOpacity(0.95),
borderRadius: BorderRadius.circular(28),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Завершить поездку?",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 16),
const Text(
"Вы действительно хотите завершить поездку?",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.4,
),
),
const SizedBox(height: 24),
// 🔹 Кнопка "Завершить" — ПЕРВАЯ, зелёная (градиент), возвращает true
Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(26),
gradient: const LinearGradient(
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
),
),
child: ElevatedButton(
onPressed: () => Navigator.pop(context, true), // ✅ true = завершить
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
),
),
child: const Text(
"Завершить",
style: TextStyle(
color: Color(0xFF1D273A), // тёмный текст на светлом градиенте
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 12),
// 🔹 Кнопка "Отмена" — ВТОРАЯ, тёмная, возвращает false
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: () => Navigator.pop(context, false), // ❌ false = отмена
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0D1024),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
),
),
child: const Text(
"Отмена",
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
],
),
),
);
}
}

View File

@@ -1,141 +0,0 @@
import 'dart:ui';
import 'package:be_happy/presentation/event/map_event.dart';
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
import 'package:be_happy/presentation/state/map_settings_modal_state.dart';
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../viewmodel/map_bloc.dart';
class MapSettingsSheet extends StatelessWidget {
final VoidCallback? onClose;
const MapSettingsSheet({super.key, this.onClose});
@override
Widget build(BuildContext context) {
return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>(
builder: (context, state) {
final List<_SettingItemData> items = [
_SettingItemData(
label: 'Геоточки',
icon: Icons.location_on_outlined,
color: const Color(0xFF66E3C4),
isActive: state.isAllGeomarksActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeomarksToggled(val)),
),
_SettingItemData(
label: 'Геозоны',
icon: Icons.gps_fixed_outlined,
color: const Color(0xFF86EFAC),
isActive: state.isAllGeozonesActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeozonesToggled(val)),
),
_SettingItemData(
label: 'Парковка',
icon: Icons.home_outlined,
color: const Color(0xFFA78BFA),
isActive: state.isParkingZoneActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
),
_SettingItemData(
label: 'Парковка запрещена',
icon: Icons.block_outlined,
color: const Color(0xFFF59E0B),
isActive: state.isRestrictedParkingZoneActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)),
),
_SettingItemData(
label: 'Запрещено кататься',
icon: Icons.warning_amber_outlined,
color: const Color(0xFFEF4444),
isActive: state.isRestrictedDrivingZoneActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedDrivingZonesToggled(val)),
),
];
return Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
height: 365,
decoration: BoxDecoration(
color: const Color(0xFF000032).withOpacity(0.88),
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Параметры карты',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
),
TextButton(
onPressed: () {
context.read<MapSettingsModalBloc>().add(ApllyButtonClick());
context.read<MapBloc>().add(UpdateMap());
Navigator.of(context).pop();
},
child: const Text(
'Готово',
style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 10),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Icon(item.icon, color: item.color),
title: Text(item.label, style: const TextStyle(color: Colors.white)),
trailing: Switch.adaptive(
value: item.isActive,
onChanged: item.onChanged,
activeTrackColor: const Color(0xFF66E3C4),
inactiveThumbColor: Colors.white,
),
);
},
),
),
],
),
),
),
),
);
},
);
}
}
// Вспомогательный класс для описания строк
class _SettingItemData {
final String label;
final IconData icon;
final Color color;
final bool isActive;
final ValueChanged<bool> onChanged;
_SettingItemData({
required this.label,
required this.icon,
required this.color,
required this.isActive,
required this.onChanged,
});
}

View File

@@ -1,13 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:be_happy/domain/entities/scooter.dart';
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.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';
import '../../../di/service_locator.dart'; import '../../../di/service_locator.dart';
import '../../event/active_ride_event.dart'; import '../../event/active_ride_event.dart';
import '../../event/map_event.dart';
import '../../state/active_ride_state.dart'; import '../../state/active_ride_state.dart';
import '../../viewmodel/active_ride_bloc.dart'; import '../../viewmodel/active_ride_bloc.dart';
import '../../viewmodel/map_bloc.dart';
import '../dialog/finish_ride_confirmation_dialog.dart';
import '../notification_toast.dart'; import '../notification_toast.dart';
class ActiveRideSheet extends StatefulWidget { class ActiveRideSheet extends StatefulWidget {
@@ -55,7 +59,7 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: BlocConsumer<ActiveRideBloc, ActiveRideState>( child: BlocConsumer<ActiveRideBloc, ActiveRideState>(
listenWhen: (previous, current) => previous.inZone != current.inZone, listenWhen: (previous, current) => previous.inZone != current.inZone || previous.status != current.status,
listener: (context, state) { listener: (context, state) {
if (!state.inZone) { if (!state.inZone) {
BotToast.showCustomNotification( BotToast.showCustomNotification(
@@ -70,6 +74,15 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
}, },
); );
} }
if (state.status == ActiveRideStatus.success && state.order != null) {
final scooter = state.order!.scooter;
context.read<MapBloc>().add(FocusOnScooter(Scooter(id: scooter.id,
title: scooter.title, status: scooter.status,
latitude: state.longitude, longitude: state.latitude,
batteryLevel: scooter.batteryLevel, isOnline: scooter.isOnline,
maxSpeed: scooter.maxSpeed, number: scooter.number)));
}
}, },
builder: (context, state) { builder: (context, state) {
// Логика отображения загрузки и ошибок остается прежней // Логика отображения загрузки и ошибок остается прежней
@@ -202,8 +215,8 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
color: Colors.white, color: Colors.white,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers', fontFamily: 'DigitalNumbers',
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -290,13 +303,23 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
child: InkWell( child: InkWell(
onTap: state.status == ActiveRideStatus.loading onTap: state.status == ActiveRideStatus.loading
? null ? null
: () { : () async {
_bloc.add(FinishRide(widget.orderId)); // 🔹 Показываем диалог подтверждения
Navigator.pop(context); final result = await showDialog<bool>(
context.go("/home/order-photos/${widget.orderId}"); context: context,
}, builder: (context) => const FinishRideConfirmationDialog(),
);
// 🔹 Если пользователь подтвердил — завершаем и переходим
if (result == true) {
_bloc.add(FinishRide(widget.orderId));
Navigator.pop(context); // закрываем ActiveRideSheet
context.go("/home/order-photos/${widget.orderId}");
}
// 🔹 Если отменил — ничего не делаем, диалог уже закрылся
},
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Column( child: Column( // ✅ Вернули Column с иконкой и текстом
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( const Icon(

View File

@@ -219,11 +219,11 @@ class _RideCardState extends State<_RideCard> {
displayTime = _elapsedTime; displayTime = _elapsedTime;
} }
final timeString = _formatDuration(displayTime); final timeString = _formatDuration(displayTime);
final statusText = _getStatusText(widget.order.status); final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный";
final statusColor = _getStatusColor(widget.order.status); final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa);
final scooterNumber = final scooterNumber =
widget.order.scooter?.number ?? widget.order.scooterId.toString(); widget.order.scooter.number ?? widget.order.scooterId.toString();
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -261,7 +261,7 @@ class _RideCardState extends State<_RideCard> {
Text( Text(
statusText, statusText,
style: TextStyle( style: TextStyle(
color: Colors.white, color: statusColor,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -269,22 +269,51 @@ class _RideCardState extends State<_RideCard> {
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Row(
scooterNumber, children: [
style: const TextStyle( Image.asset("assets/icons/qr_icon_order.png"),
color: Colors.white, const SizedBox(width: 6),
fontSize: 16, Text(
fontWeight: FontWeight.w600, scooterNumber,
), style: const TextStyle(
), color: Colors.white,
const SizedBox(height: 4), fontSize: 16,
Text( fontWeight: FontWeight.w600,
_getLocationText(), ),
style: TextStyle( ),
color: Colors.white.withOpacity(0.6), ],
fontSize: 13,
),
), ),
if (isReserved)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
"Тариф",
style: TextStyle(
color: Color(0xFF8bffaa),
fontSize: 11,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("assets/icons/timer.png", width: 14,),
const SizedBox(width: 4),
Text(
widget.order.plan?.title ?? "Название тарифа",
style: TextStyle(
color: Color(0xFF8bffaa),
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
)
],
)
], ],
), ),
), ),
@@ -301,10 +330,10 @@ class _RideCardState extends State<_RideCard> {
timeString, timeString,
style: TextStyle( style: TextStyle(
color: statusColor, color: statusColor,
fontSize: 26, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers', fontFamily: 'DigitalNumbers',
), ),
), ),
], ],
@@ -360,7 +389,7 @@ class _RideCardState extends State<_RideCard> {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case 'reserved': case 'reserved':
case 'holding': case 'holding':
return 'Забронировано'; return 'Забронирован';
case 'active': case 'active':
case 'in_progress': case 'in_progress':
return 'Активно'; return 'Активно';

View File

@@ -5,8 +5,6 @@ import 'package:be_happy/presentation/state/map_settings_modal_state.dart';
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; import 'package:be_happy/presentation/viewmodel/map_settings_modal_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 '../../viewmodel/map_bloc.dart'; import '../../viewmodel/map_bloc.dart';
class MapSettingsSheet extends StatelessWidget { class MapSettingsSheet extends StatelessWidget {
@@ -19,13 +17,6 @@ class MapSettingsSheet extends StatelessWidget {
return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>( return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>(
builder: (context, state) { builder: (context, state) {
final List<_SettingItemData> items = [ final List<_SettingItemData> items = [
_SettingItemData(
label: 'Геоточки',
icon: Icons.location_on_outlined,
color: const Color(0xFF66E3C4),
isActive: state.isAllGeomarksActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeomarksToggled(val)),
),
_SettingItemData( _SettingItemData(
label: 'Геозоны', label: 'Геозоны',
icon: Icons.gps_fixed_outlined, icon: Icons.gps_fixed_outlined,
@@ -41,9 +32,9 @@ class MapSettingsSheet extends StatelessWidget {
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)), onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
), ),
_SettingItemData( _SettingItemData(
label: 'Разрешено кататься', label: 'Парковка запрещена',
icon: Icons.block_outlined, icon: Icons.block_outlined,
color: const Color(0xFF5ECD4C), color: const Color(0xFFF59E0B),
isActive: state.isRestrictedParkingZoneActive, isActive: state.isRestrictedParkingZoneActive,
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)), onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)),
), ),

View File

@@ -12,11 +12,13 @@ import '../../state/payment_method_sheet_state.dart';
import '../../viewmodel/payment_method_sheet_bloc.dart'; import '../../viewmodel/payment_method_sheet_bloc.dart';
class PaymentMethodSheet extends StatefulWidget { class PaymentMethodSheet extends StatefulWidget {
final PaymentCard? initialSelectedCard; // Добавляем это поле final PaymentCard? initialSelectedCard;
final bool showBalance;
const PaymentMethodSheet({ const PaymentMethodSheet({
super.key, super.key,
this.initialSelectedCard, // Инициализируем в конструкторе this.initialSelectedCard,
this.showBalance = true,
}); });
@override @override
@@ -92,7 +94,7 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
_selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1; _selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1;
} else { } else {
final mainCardIndex = state.cards.indexWhere((card) => card.isMain); final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
_selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1; _selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : (widget.showBalance ? -1 : 0);
} }
} }
@@ -169,19 +171,20 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: Column(
children: [ children: [
PaymentOption( if (widget.showBalance) ...[
title: 'Баланс', PaymentOption(
subtitle: '${state.balance.toStringAsFixed(2)} BYN', title: 'Баланс',
isSelected: _selectedPaymentMethod == -1, subtitle: '${state.balance.toStringAsFixed(2)} BYN',
onTap: () { isSelected: _selectedPaymentMethod == -1,
setState(() { onTap: () {
_selectedPaymentMethod = -1; setState(() {
}); _selectedPaymentMethod = -1;
Navigator.pop(context, 'balance'); });
}, Navigator.pop(context, 'balance');
), },
),
const SizedBox(height: 12), const SizedBox(height: 12),
],
...state.cards.asMap().entries.map((entry) { ...state.cards.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;

View File

@@ -14,7 +14,7 @@ class ReservedRideSheet extends StatefulWidget {
final String scooterNumber; final String scooterNumber;
final int orderId; final int orderId;
final Duration initialReservationTime; final Duration initialReservationTime;
const ReservedRideSheet({ const ReservedRideSheet({
super.key, super.key,
required this.scooterNumber, required this.scooterNumber,
@@ -60,264 +60,286 @@ class _ReservedRideSheetState extends State<ReservedRideSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: Align( child: Scaffold(
alignment: Alignment.bottomCenter, backgroundColor: Colors.transparent,
child: ClipRRect( resizeToAvoidBottomInset: false,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), body: Align(
child: BackdropFilter( alignment: Alignment.bottomCenter,
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: ClipRRect(
child: Container( borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
padding: const EdgeInsets.only(top: 20, bottom: 10), child: BackdropFilter(
decoration: BoxDecoration( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
color: const Color(0xFF000032).withOpacity(0.5), child: Container(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), padding: const EdgeInsets.only(top: 20, bottom: 10),
), decoration: BoxDecoration(
child: Column( color: const Color(0xFF000032).withOpacity(0.5),
mainAxisSize: MainAxisSize.min, borderRadius: const BorderRadius.vertical(
children: [ top: Radius.circular(30),
// HEADER
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Row(
children: [
Icon(
Icons.arrow_back_ios_sharp,
color: const Color(0x99FFFFFF),
size: 20,
),
Icon(
Icons.arrow_back_ios_sharp,
color: const Color(0x66FFFFFF),
size: 20,
),
Icon(
Icons.arrow_back_ios_sharp,
color: const Color(0x22FFFFFF),
size: 20,
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Бесплатное бронирование',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
), ),
),
const SizedBox(height: 20), child: Column(
mainAxisSize: MainAxisSize.min,
// ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ) children: [
Padding( // HEADER
padding: const EdgeInsets.symmetric(horizontal: 20), Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 20),
children: [ child: Row(
// Таймер children: [
Expanded( GestureDetector(
flex: 2, onTap: () => Navigator.pop(context),
child: Text(
_formatDuration(_reservationTime),
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers',
),
),
),
// Иконка и информация (ВЫСОКИЙ БЛОК)
Expanded(
flex: 3,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row( child: Row(
children: [ children: [
// Иконка самоката (ВЫШЕ) Icon(
SizedBox( Icons.arrow_back_ios_sharp,
width: 44, color: const Color(0x99FFFFFF),
height: 56, size: 20,
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
),
), ),
const SizedBox(width: 12), Icon(
// Инфо Icons.arrow_back_ios_sharp,
Expanded( color: const Color(0x66FFFFFF),
child: Column( size: 20,
crossAxisAlignment: CrossAxisAlignment.start, ),
mainAxisSize: MainAxisSize.min, Icon(
children: [ Icons.arrow_back_ios_sharp,
Row( color: const Color(0x22FFFFFF),
mainAxisSize: MainAxisSize.min, size: 20,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFFFB800),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
'Забронирован',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
],
),
const SizedBox(height: 8),
Text(
'${widget.scooterNumber}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
), ),
], ],
), ),
), ),
), const SizedBox(width: 12),
], Expanded(
), child: Text(
), 'Бесплатное бронирование',
style: const TextStyle(
const SizedBox(height: 16), color: Colors.white,
fontSize: 16,
// КНОПКА "НАЧАТЬ ПОЕЗДКУ" fontWeight: FontWeight.w600,
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 20), overflow: TextOverflow.ellipsis,
child: BlocListener<ReservedRideBloc, ReservedRideState>(
listener: (context, state) {
if (state.rideStarted) {
Navigator.pop(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ActiveRideSheet(
scooterNumber: widget.scooterNumber,
initialElapsedTime: Duration.zero,
orderId: widget.orderId,
), ),
); ),
} else if (state.status == ReservedRideStatus.failure) { ],
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
);
}
},
child: GradientButton(
text: 'Начать поездку',
showArrows: true,
height: 48,
width: double.infinity,
fontSize: 15,
onTap: () {
_bloc.add(StartRide(widget.orderId));
},
), ),
), ),
),
const SizedBox(height: 12), const SizedBox(height: 20),
// КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ" // ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocListener<ReservedRideBloc, ReservedRideState>( child: Row(
listener: (context, state) { children: [
if (state.rideCancelled) { // Таймер
Navigator.pop(context); Expanded(
} else if (state.status == ReservedRideStatus.failure) { flex: 2,
ScaffoldMessenger.of(context).showSnackBar( child: Text(
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')), _formatDuration(_reservationTime),
); style: const TextStyle(
} color: Colors.white,
}, fontSize: 32,
child: Container( fontWeight: FontWeight.bold,
height: 48, fontFeatures: [FontFeature.tabularFigures()],
decoration: BoxDecoration( fontFamily: 'Digital Numbers',
borderRadius: BorderRadius.circular(24), ),
border: Border.all( ),
color: Colors.white.withOpacity(0.4),
width: 1,
), ),
), // Иконка и информация (ВЫСОКИЙ БЛОК)
child: Material( Expanded(
color: Colors.transparent, flex: 3,
child: InkWell( child: Container(
onTap: () async { padding: const EdgeInsets.symmetric(
final result = await showDialog<bool>( horizontal: 12,
context: context, vertical: 16,
builder: (context) => const CancelBookingDialog(), ),
); decoration: BoxDecoration(
if (result != null && result) { color: Colors.white.withOpacity(0.1),
_bloc.add(CancelRide(widget.orderId)); borderRadius: BorderRadius.circular(12),
} ),
}, child: Row(
borderRadius: BorderRadius.circular(24), children: [
child: BlocBuilder<ReservedRideBloc, ReservedRideState>( // Иконка самоката (ВЫШЕ)
builder: (context, state) { SizedBox(
if (state.status == ReservedRideStatus.loading) { width: 44,
return const Center( height: 56,
child: SizedBox( child: Image.asset(
width: 20, 'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
height: 20, fit: BoxFit.contain,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
);
}
return const Center(
child: Text(
'Отменить бронирование',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 12),
// Инфо
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFFFB800),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
'Забронирован',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
],
),
const SizedBox(height: 8),
Text(
'${widget.scooterNumber}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
),
],
),
),
const SizedBox(height: 16),
// КНОПКА "НАЧАТЬ ПОЕЗДКУ"
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocListener<ReservedRideBloc, ReservedRideState>(
listener: (context, state) {
if (state.rideStarted) {
Navigator.pop(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ActiveRideSheet(
scooterNumber: widget.scooterNumber,
initialElapsedTime: Duration.zero,
orderId: widget.orderId,
),
);
} else if (state.status ==
ReservedRideStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Ошибка'),
),
);
}
},
child: GradientButton(
text: 'Начать поездку',
showArrows: true,
height: 48,
width: double.infinity,
fontSize: 15,
onTap: () {
_bloc.add(StartRide(widget.orderId));
},
),
),
),
const SizedBox(height: 12),
// КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ"
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocListener<ReservedRideBloc, ReservedRideState>(
listener: (context, state) {
if (state.rideCancelled) {
Navigator.pop(context);
} else if (state.status ==
ReservedRideStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Ошибка'),
),
);
}
},
child: Container(
height: 48,
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: () async {
final result = await showDialog<bool>(
context: context,
builder: (context) =>
const CancelBookingDialog(),
); );
if (result != null && result) {
_bloc.add(CancelRide(widget.orderId));
}
}, },
borderRadius: BorderRadius.circular(24),
child:
BlocBuilder<
ReservedRideBloc,
ReservedRideState
>(
builder: (context, state) {
if (state.status ==
ReservedRideStatus.loading) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
);
}
return const Center(
child: Text(
'Отменить бронирование',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
);
},
),
), ),
), ),
), ),
), ),
), ),
),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
),
), ),
), ),
), ),

View File

@@ -1,13 +1,18 @@
import 'package:be_happy/presentation/components/gradient_button.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../domain/entities/subscription.dart'; import '../../domain/entities/subscription.dart';
import '../event/subscription_list_event.dart';
class SubscriptionCard extends StatelessWidget { class SubscriptionCard extends StatelessWidget {
final Subscription subscription; final Subscription subscription;
final bool isActive; final bool isActive;
final DateTime? expiredAt;
final VoidCallback? onRefresh;
const SubscriptionCard({super.key, required this.subscription, required this.isActive}); const SubscriptionCard({super.key, required this.subscription, required this.isActive, this.expiredAt, this.onRefresh});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -15,9 +20,9 @@ class SubscriptionCard extends StatelessWidget {
? subscription.options.reduce((a, b) => a.price < b.price ? a : b) ? subscription.options.reduce((a, b) => a.price < b.price ? a : b)
: null; : null;
final maxDaysOption = subscription.options.isNotEmpty /*final maxDaysOption = subscription.options.isNotEmpty
? subscription.options.reduce((a, b) => a.days > b.days ? a : b) ? subscription.options.reduce((a, b) => a.days > b.days ? a : b)
: null; : null;*/
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@@ -43,7 +48,7 @@ class SubscriptionCard extends StatelessWidget {
padding: EdgeInsets.all(4), padding: EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3), color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
"АКТИВНА", "АКТИВНА",
@@ -64,12 +69,19 @@ class SubscriptionCard extends StatelessWidget {
subscription.shortDescription, subscription.shortDescription,
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14), style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14),
), ),
const SizedBox(height: 16), if (isActive && expiredAt != null) ...[
if (maxDaysOption != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Builder(
"Период действия: до ${maxDaysOption.days} дней", builder: (context) {
style: const TextStyle(color: Colors.white, fontSize: 14), final day = expiredAt!.day.toString().padLeft(2, '0');
final month = expiredAt!.month.toString().padLeft(2, '0');
final year = expiredAt!.year;
return Text(
"Период действия: до $day.$month.$year",
style: const TextStyle(color: Colors.white, fontSize: 14),
);
},
), ),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -83,15 +95,17 @@ class SubscriptionCard extends StatelessWidget {
) )
else else
const SizedBox.shrink(), const SizedBox.shrink(),
ElevatedButton( GradientButton(
onPressed: () => context.push("/home/subscriptions/${subscription.id}"), onTap: () async {
style: ElevatedButton.styleFrom( final isSubscribed = await context.push<bool>("/home/subscriptions/${subscription.id}");
backgroundColor: const Color(0xFF80FFD1), if (isSubscribed == true && onRefresh != null) {
foregroundColor: Colors.black, onRefresh!();
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), }
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), },
), text: "Подробнее",
child: const Text("Подробнее", style: TextStyle(fontWeight: FontWeight.bold)), enabled: true,
width: 120,
height: 40,
), ),
], ],
), ),

View File

@@ -30,4 +30,14 @@ class NotificationReceived extends ScooterEvent {
NotificationReceived(this.notification); NotificationReceived(this.notification);
} }
class FocusOnScooter extends ScooterEvent {
final Scooter scooter;
FocusOnScooter(this.scooter);
}
class ClearMapPlacemarks extends ScooterEvent {}
class ClearMapFocus extends ScooterEvent {}

View File

@@ -0,0 +1,3 @@
sealed class NotificationsEvent {}
class NotificationsFetchRequested extends NotificationsEvent {}

View File

@@ -58,9 +58,9 @@ import '../../domain/usecase/remove_payment_card_usecase.dart';
import '../../domain/usecase/save_map_settings_usecase.dart'; import '../../domain/usecase/save_map_settings_usecase.dart';
import '../../domain/usecase/set_main_payment_card_usecase.dart'; import '../../domain/usecase/set_main_payment_card_usecase.dart';
import '../../domain/usecase/verify_pin_usecase.dart'; import '../../domain/usecase/verify_pin_usecase.dart';
import '../components/map_settings_sheet.dart';
import '../components/scooter_bottom_sheet.dart'; import '../components/scooter_bottom_sheet.dart';
import '../components/sheet/current_rides_sheet.dart'; import '../components/sheet/current_rides_sheet.dart';
import '../components/sheet/map_settings_sheet.dart';
import '../components/sheet/payment_method_sheet.dart'; import '../components/sheet/payment_method_sheet.dart';
import '../components/sheet/reserved_ride_sheet.dart'; import '../components/sheet/reserved_ride_sheet.dart';
import '../components/sheet/tariff_sheet.dart'; import '../components/sheet/tariff_sheet.dart';
@@ -75,6 +75,7 @@ import '../event/tariff_sheet_event.dart';
import '../event/top_up_event.dart'; import '../event/top_up_event.dart';
import '../screens/add_card_screen.dart'; // ← новый импорт import '../screens/add_card_screen.dart'; // ← новый импорт
import '../screens/license_agreement_screen.dart'; import '../screens/license_agreement_screen.dart';
import '../screens/notifications_screen.dart';
import '../screens/order_history_screen.dart'; import '../screens/order_history_screen.dart';
import '../screens/payment_methods_screen.dart'; import '../screens/payment_methods_screen.dart';
import '../screens/phone_login_screen.dart'; import '../screens/phone_login_screen.dart';
@@ -371,6 +372,7 @@ class AppRouter {
SubscriptionDetailsBloc( SubscriptionDetailsBloc(
getIt<GetSubscriptionByIdUsecase>(), getIt<GetSubscriptionByIdUsecase>(),
getIt<ActivateSubscriptionUsecase>(), getIt<ActivateSubscriptionUsecase>(),
getIt<GetClientSubscriptionsUsecase>(),
) )
..add( ..add(
LoadDetailsEvent( LoadDetailsEvent(
@@ -456,6 +458,10 @@ class AppRouter {
builder: (context, state) => const OrderHistoryScreen(), builder: (context, state) => const OrderHistoryScreen(),
routes: [] routes: []
), ),
GoRoute(
path: 'notifications',
builder: (context, state) => const NotificationsScreen(),
),
], ],
), ),
], ],

View File

@@ -19,11 +19,14 @@ class AddCardScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: BlocListener<AddCardBloc, AddCardState>( body: BlocListener<AddCardBloc, AddCardState>(
listenWhen: (previous, current) => listenWhen: (previous, current) {
previous.status != current.status && print(
current.status == AddCardStatus.success, 'Смена статуса: ${previous.status} -> ${current.status} ${current.errorMessage}');
return previous.status != current.status &&
current.status == AddCardStatus.success;
},
listener: (context, state) { listener: (context, state) {
context.pop(); context.pop(true);
}, },
child: Container( child: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
@@ -35,6 +38,7 @@ class AddCardScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
const CustomAppBar(title: 'Добавление карты'), const CustomAppBar(title: 'Добавление карты'),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -148,10 +152,7 @@ class AddCardScreen extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: state.isFormValid onTap: state.isFormValid
? () => { ? () => {
context.read<AddCardBloc>().add( context.read<AddCardBloc>().add(AddCardSubmitted()),
AddCardSubmitted()),
context.read<PaymentMethodsBloc>()..add(PaymentMethodsStarted()),
context.pop()
} }
: null, : null,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart'; import '../components/custom_app_bar.dart';
@@ -17,12 +18,10 @@ class DocumentsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: Column(
children: [ children: [
// ✅ Используем общий AppBar
const SizedBox(height: 16), const SizedBox(height: 16),
CustomAppBar(title: 'Документы'), CustomAppBar(title: 'Документы'),
const SizedBox(height: 32), const SizedBox(height: 32),
// Список ссылок
LinkRow( LinkRow(
icon: 'assets/icons/doc.png', icon: 'assets/icons/doc.png',
title: 'Договор аренды', title: 'Договор аренды',
@@ -33,14 +32,14 @@ class DocumentsScreen extends StatelessWidget {
LinkRow( LinkRow(
icon: 'assets/icons/doc.png', icon: 'assets/icons/doc.png',
title: 'Политика конфиденциальности', title: 'Политика конфиденциальности',
onTap: () => openLink('https://...'), onTap: () => context.push('/privacy-policy')
), ),
const Divider(height: 1, color: Colors.white24), const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),
LinkRow( LinkRow(
icon: 'assets/icons/doc.png', icon: 'assets/icons/doc.png',
title: 'Правила вождения', title: 'Правила вождения',
onTap: () => openLink('https://...'), onTap: () => openLink('https://behappybel.by/#rule'),
), ),
const Divider(height: 1, color: Colors.white24), const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -14,7 +14,7 @@ class LicenseAgreementScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
// 🔹 APPBAR С КНОПКОЙ НАЗАД const SizedBox(height: 16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 20), padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: ' '), child: CustomAppBar(title: ' '),

View File

@@ -93,7 +93,9 @@ class _MapScreenState extends State<MapScreen> {
listenWhen: (previous, current) { listenWhen: (previous, current) {
return current.lastNotification != return current.lastNotification !=
previous.lastNotification || previous.lastNotification ||
current.flags != previous.flags; current.flags != previous.flags ||
previous.selectedScooterForFocus?.id
!= current.selectedScooterForFocus?.id;
}, },
listener: (context, state) { listener: (context, state) {
@@ -164,14 +166,39 @@ class _MapScreenState extends State<MapScreen> {
); );
} }
} }
if (state.selectedScooterForFocus != null) {
final targetScooter = state.selectedScooterForFocus!;
print("RESERVED SCOOTER: $targetScooter");
_moveCameraToPoint(
targetScooter.longitude,
targetScooter.latitude,
zoom: 17,
);
context.read<MapBloc>().add(ClearMapFocus());
}
}, },
buildWhen: (previous, current) => buildWhen: (previous, current) {
previous.scooters != current.scooters || return previous.scooters != current.scooters ||
previous.zones != current.zones, previous.reservedScooters != current.reservedScooters ||
previous.zones != current.zones ||
previous.status != current.status;
},
builder: (context, state) { builder: (context, state) {
final scooters = _buildScooterPlacemarks( final freeScooters = _buildScooterPlacemarks(
state.scooters, scooters: state.scooters,
state.address ?? "Unknown address", iconAsset: 'assets/icons/scooter_placemark_fill.png',
isClickable: true,
);
final reservedScooters = _buildScooterPlacemarks(
scooters: state.reservedScooters ?? [],
iconAsset: 'assets/icons/scooter_reserved_placemark_fill.png',
isClickable: false,
); );
final zonePolygons = _buildZonePolygons(state.zones); final zonePolygons = _buildZonePolygons(state.zones);
@@ -193,9 +220,10 @@ class _MapScreenState extends State<MapScreen> {
}, },
mapObjects: [ mapObjects: [
...zonePolygons, ...zonePolygons,
...reservedScooters,
ClusterizedPlacemarkCollection( ClusterizedPlacemarkCollection(
mapId: const MapObjectId('scooters_cluster'), mapId: const MapObjectId('scooters_cluster'),
placemarks: scooters, placemarks: freeScooters,
radius: 30, radius: 30,
minZoom: 15, minZoom: 15,
consumeTapEvents: true, consumeTapEvents: true,
@@ -232,7 +260,6 @@ class _MapScreenState extends State<MapScreen> {
}, },
), ),
// Индикатор загрузки (отдельный строитель для статуса)
BlocBuilder<MapBloc, ScooterState>( BlocBuilder<MapBloc, ScooterState>(
buildWhen: (previous, current) => buildWhen: (previous, current) =>
previous.status != current.status, previous.status != current.status,
@@ -378,70 +405,42 @@ class _MapScreenState extends State<MapScreen> {
} }
void _onMarkerTap(List<Scooter> scooters) async { void _onMarkerTap(List<Scooter> scooters) async {
context.read<MapBloc>().add(CheckUser());
final flags = context.read<MapBloc>().state.flags;
if (!flags.hasCard) {
_showExistingNotification(
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasUnpaidOrder) {
_showExistingNotification(
child: UnpaidOrderNotificationCard(
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasFine) {
_showExistingNotification(
child: FineNotificationCard(
onClose: () => BotToast.cleanAll(),
),
);
return;
}
context.push( context.push(
"/home/scooter-sheet", "/home/scooter-sheet",
extra: {'scooters': scooters, 'currentLocation': _currentPosition}, extra: {'scooters': scooters, 'currentLocation': _currentPosition},
); );
/*final scoot = await showModalBottomSheet<Scooter>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) {
return BlocProvider(
create: (context) =>
ScooterDetailModalBloc(
getIt<GetAddressByPointUsecase>(),
getIt<GetScooterUsecase>(),
getIt<GetPedestrianRoutesUsecase>(),
)..add(
ScooterDetailModalStarted(
scooters,
_currentPosition!.latitude,
_currentPosition!.longitude,
),
),
child: ScooterBottomSheet(),
);
},
);*/
/*bool? isBooking = false;
if (scoot != null) {
final result = await context.push('/home/scooter/${scoot.id}');
if (result == true) {
// Даем небольшую задержку, чтобы навигация завершилась корректно
await Future.delayed(Duration(milliseconds: 300), () async {
isBooking = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) => BlocProvider(
create: (context) => TariffSheetBloc(
getIt<GetAvailableTariffsUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<BookScooterUsecase>(),
),
child: TariffSheet(scooter: scoot),
),
);
});
}
}
if (isBooking ?? false) {
showModalBottomSheet(
context: context,
builder: (context) => BlocProvider(
create: (context) =>
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
..add(LoadClientOrders(1)),
child: CurrentRidesSheet(clientId: 1),
),
);
}*/
} }
void _onMapSettingsTap() { void _onMapSettingsTap() {
@@ -582,26 +581,26 @@ class _MapScreenState extends State<MapScreen> {
await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png'); await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png');
} }
List<PlacemarkMapObject> _buildScooterPlacemarks( List<PlacemarkMapObject> _buildScooterPlacemarks({
List<Scooter> scooters, required List<Scooter> scooters,
String address, required String iconAsset,
) { required bool isClickable,
}) {
return scooters.map((scooter) { return scooters.map((scooter) {
return PlacemarkMapObject( return PlacemarkMapObject(
mapId: MapObjectId('${scooter.id}'), mapId: MapObjectId('${isClickable ? "" : "reserved_"}${scooter.id}'), // уникальный ID для карты
point: Point(latitude: scooter.longitude, longitude: scooter.latitude), point: Point(latitude: scooter.longitude, longitude: scooter.latitude),
icon: PlacemarkIcon.single( icon: PlacemarkIcon.single(
PlacemarkIconStyle( PlacemarkIconStyle(
image: BitmapDescriptor.fromAssetImage( image: BitmapDescriptor.fromAssetImage(iconAsset),
'assets/icons/scooter_placemark_fill.png',
),
scale: 0.2, scale: 0.2,
), ),
), ),
opacity: 1.0, opacity: 1.0,
onTap: (object, point) async => { consumeTapEvents: isClickable,
_onMarkerTap([scooter]), onTap: isClickable
}, ? (object, point) async => _onMarkerTap([scooter])
: null,
); );
}).toList(); }).toList();
} }
@@ -696,13 +695,13 @@ class _MapScreenState extends State<MapScreen> {
children: [ children: [
_RoundIconButton( _RoundIconButton(
icon: Icons.notifications_sharp, icon: Icons.notifications_sharp,
onPressed: _onNotificationTap, onPressed: () => context.push("/home/notifications"),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_RoundIconButton( _RoundButton(
icon: Icons.directions_run, imagePath: 'assets/icons/scooter_placemark.png',
onPressed: () => context.push("/home/current-rides-sheet"), onPressed: () => context.push("/home/current-rides-sheet"),
), )
], ],
), ),
), ),
@@ -717,7 +716,42 @@ class _MapScreenState extends State<MapScreen> {
right: 0, right: 0,
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: () => context.push("/home/qr-info"), onTap: () {
final flags = context.read<MapBloc>().state.flags;
// 🔹 Проверка флагов — показываем те же карточки, что и в listener
if (!flags.hasCard) {
_showExistingNotification(
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasUnpaidOrder) {
_showExistingNotification(
child: UnpaidOrderNotificationCard(
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasFine) {
_showExistingNotification(
child: FineNotificationCard(
onClose: () => BotToast.cleanAll(),
),
);
return;
}
// ✅ Все проверки пройдены — переход на сканирование
context.push("/home/qr-info");
},
child: Container( child: Container(
width: 64, width: 64,
height: 64, height: 64,
@@ -810,6 +844,40 @@ class _RoundIconButton extends StatelessWidget {
} }
} }
class _RoundButton extends StatelessWidget {
final String imagePath;
final VoidCallback onPressed;
const _RoundButton({
required this.imagePath,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.darkBlue,
borderRadius: BorderRadius.circular(12),
),
child: GestureDetector(
onTap: onPressed,
child: Center(
child: Image.asset(
imagePath,
width: 20,
height: 20,
fit: BoxFit.contain,
color: Colors.white,
),
),
),
);
}
}
class _CircleIconButton extends StatelessWidget { class _CircleIconButton extends StatelessWidget {
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -832,3 +900,18 @@ class _CircleIconButton extends StatelessWidget {
); );
} }
} }
void _showExistingNotification({required Widget child}) {
BotToast.showCustomNotification(
duration: null, // не исчезает, пока пользователь не закроет
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120), // тот же отступ, что в listener
child: Material(
color: Colors.transparent,
child: child, // PaymentNotificationCard / UnpaidOrderNotificationCard / FineNotificationCard
),
);
},
);
}

View File

@@ -61,7 +61,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
// 🔹 Заголовок в AppBar const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: widget.title), child: CustomAppBar(title: widget.title),

View File

@@ -4,6 +4,7 @@ import 'dart:developer' as dev;
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../di/service_locator.dart'; import '../../di/service_locator.dart';
import '../components/custom_app_bar.dart'; import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../event/news_event.dart'; import '../event/news_event.dart';
import '../state/news_state.dart'; import '../state/news_state.dart';
import '../viewmodel/news_bloc.dart'; import '../viewmodel/news_bloc.dart';
@@ -14,11 +15,8 @@ class NewsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
dev.log('🔍 NewsScreen: Создание экрана новостей');
return BlocProvider( return BlocProvider(
create: (context) { create: (context) {
dev.log('🔍 NewsScreen: Создание NewsBloc');
return getIt<NewsBloc>()..add(const NewsFetchRequested()); return getIt<NewsBloc>()..add(const NewsFetchRequested());
}, },
child: const NewsView(), child: const NewsView(),
@@ -31,7 +29,6 @@ class NewsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
dev.log('🔍 NewsView: Построение UI');
return Scaffold( return Scaffold(
body: Container( body: Container(
@@ -48,7 +45,6 @@ class NewsView extends StatelessWidget {
Expanded( Expanded(
child: BlocBuilder<NewsBloc, NewsState>( child: BlocBuilder<NewsBloc, NewsState>(
builder: (context, state) { builder: (context, state) {
dev.log('🔍 NewsView: Состояние ${state.status}, новостей: ${state.news.length}');
if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) { if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) {
return const Center( return const Center(
@@ -84,7 +80,6 @@ class NewsView extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
dev.log('🔍 NewsView: Повторная загрузка');
context.read<NewsBloc>().add(const NewsFetchRequested()); context.read<NewsBloc>().add(const NewsFetchRequested());
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -173,7 +168,7 @@ class _NewsCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141530), color: const Color(0xFF0A0F2E).withOpacity(0.7),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: Column(
@@ -196,6 +191,18 @@ class _NewsCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container(
width: double.infinity,
height: 80,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/news_def.png'),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 16),
Text( Text(
news.previewText, news.previewText,
style: const TextStyle( style: const TextStyle(
@@ -205,51 +212,28 @@ class _NewsCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox(
height: 40, Align(
child: OutlinedButton( alignment: Alignment.centerRight,
onPressed: () { child: ConstrainedBox(
Navigator.push( constraints: const BoxConstraints(maxWidth: 150),
context, child: GradientButton(
MaterialPageRoute( text: 'Подробнее',
builder: (context) => NewsDetailScreen( onTap: () {
newsId: news.id, Navigator.push(
title: news.title, context,
MaterialPageRoute(
builder: (context) => NewsDetailScreen(
newsId: news.id,
title: news.title,
),
), ),
), );
); },
}, showArrows: true,
style: OutlinedButton.styleFrom( fontSize: 14,
shape: RoundedRectangleBorder( height: 40,
borderRadius: BorderRadius.circular(24), width: double.infinity,
),
side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Подробнее',
style: TextStyle(color: AppColors.smsDigit),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit,
),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit.withOpacity(0.6),
),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit.withOpacity(0.3),
),
],
), ),
), ),
), ),

View File

@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:developer' as dev;
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/client_notification.dart';
import '../components/custom_app_bar.dart';
import '../event/notifications_event.dart';
import '../state/notifications_state.dart';
import '../viewmodel/notifications_bloc.dart';
enum NotificationFilter {
all,
auth,
payment,
order,
}
class NotificationsScreen extends StatelessWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context) {
return _NotificationsScreenContent();
}
}
class _NotificationsScreenContent extends StatefulWidget {
const _NotificationsScreenContent();
@override
State<_NotificationsScreenContent> createState() => _NotificationsScreenContentState();
}
class _NotificationsScreenContentState extends State<_NotificationsScreenContent> {
NotificationFilter _filter = NotificationFilter.all;
void _setFilter(NotificationFilter filter) {
setState(() {
_filter = filter;
});
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<NotificationsBloc>()..add(NotificationsFetchRequested()),
child: NotificationsView(
filter: _filter,
onFilterChanged: _setFilter,
),
);
}
}
class NotificationsView extends StatelessWidget {
final NotificationFilter filter;
final ValueChanged<NotificationFilter> onFilterChanged;
const NotificationsView({
super.key,
this.filter = NotificationFilter.all,
required this.onFilterChanged,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Уведомления'),
),
const SizedBox(height: 16),
_buildFilterBar(),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<NotificationsBloc, NotificationsState>(
builder: (context, state) {
if (state.status == NotificationsStatus.loading) {
return const Center(child: CircularProgressIndicator(color: Colors.white));
}
if (state.status == NotificationsStatus.failure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Ошибка загрузки уведомлений',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
state.errorMessage ?? '',
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
context.read<NotificationsBloc>().add(NotificationsFetchRequested());
},
child: const Text('Повторить'),
),
],
),
);
}
final filtered = state.notifications.where((n) {
switch (filter) {
case NotificationFilter.all:
return true;
case NotificationFilter.auth:
return n.category == NotificationCategory.auth;
case NotificationFilter.payment:
return n.category == NotificationCategory.payment;
case NotificationFilter.order:
return n.category == NotificationCategory.scooter;
// || n.category == NotificationCategory.adminInfo
// || n.category == NotificationCategory.companyInfo;
default:
return true;
}
}).toList();
if (filtered.isEmpty) {
return const _EmptyState();
}
return RefreshIndicator(
onRefresh: () async {
context.read<NotificationsBloc>().add(NotificationsFetchRequested());
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: filtered.length,
itemBuilder: (context, index) {
return _NotificationCard(notification: filtered[index]);
},
),
);
},
),
),
],
),
),
),
);
}
Widget _buildFilterBar() {
final items = [
{'label': 'Все', 'value': NotificationFilter.all},
{'label': 'Авторизация', 'value': NotificationFilter.auth},
{'label': 'Оплата', 'value': NotificationFilter.payment},
{'label': 'Поездка', 'value': NotificationFilter.order},
];
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: items.map((item) {
final isActive = item['value'] == filter;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onFilterChanged(item['value'] as NotificationFilter),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: isActive ? AppColors.activeButtonGradient : null,
color: isActive ? null : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive
? Colors.transparent
: Colors.white.withOpacity(0.4),
width: 1,
),
),
child: Text(
item['label'] as String,
textAlign: TextAlign.center,
style: TextStyle(
color: isActive
? AppColors.activeButtonText
: Colors.white,
fontSize: 14,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
),
),
),
),
);
}).toList(),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/notification_empty.png',
width: 280,
height: 280,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.notifications_none_outlined,
size: 120,
color: Colors.white38,
);
},
),
const SizedBox(height: 32),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 40),
child: Text(
'У вас пока нет уведомлений.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 16,
height: 1.5,
),
),
),
],
);
}
}
class _NotificationCard extends StatelessWidget {
final ClientNotification notification;
const _NotificationCard({required this.notification});
@override
Widget build(BuildContext context) {
final date = _formatDate(notification.createdAt);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF141530).withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 8),
Text(
notification.content,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else if (difference.inDays == 1) {
return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else {
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}';
}
}
String _getTypeLabel(NotificationType type) {
switch (type) {
case NotificationType.info:
return 'Информация';
case NotificationType.attention:
return 'Внимание';
case NotificationType.warning:
return 'Предупреждение';
}
}
String _getCategoryLabel(NotificationCategory category) {
switch (category) {
case NotificationCategory.auth:
return 'Авторизация';
case NotificationCategory.zone:
return 'Зоны';
case NotificationCategory.payment:
return 'Оплата';
case NotificationCategory.companyInfo:
return 'Акции';
case NotificationCategory.adminInfo:
return 'Админ';
case NotificationCategory.scooter:
return 'Самокат';
}
}
}

View File

@@ -32,7 +32,7 @@ class OrderHistoryDetailScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
// 🔹 HEADER const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Поездка $date'), child: CustomAppBar(title: 'Поездка $date'),

View File

@@ -32,6 +32,7 @@ class OrderHistoryView extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 20), padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'История поездок'), child: CustomAppBar(title: 'История поездок'),

View File

@@ -9,9 +9,11 @@ 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/map_event.dart';
import '../event/payment_confirm_event.dart'; import '../event/payment_confirm_event.dart';
import '../event/payment_method_sheet_event.dart'; import '../event/payment_method_sheet_event.dart';
import '../state/payment_confirm_state.dart'; import '../state/payment_confirm_state.dart';
import '../viewmodel/map_bloc.dart';
import '../viewmodel/payment_confirm_bloc.dart'; import '../viewmodel/payment_confirm_bloc.dart';
import '../viewmodel/payment_method_sheet_bloc.dart'; import '../viewmodel/payment_method_sheet_bloc.dart';
@@ -62,6 +64,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 20), padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Завершение поездки'), child: CustomAppBar(title: 'Завершение поездки'),
@@ -77,6 +80,7 @@ class _PaymentConfirmScreenContent extends StatelessWidget {
listener: (context, state) { listener: (context, state) {
if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) { if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) {
context.read<MapBloc>().add(ClearMapPlacemarks());
context.go('/home'); context.go('/home');
} else if (state.status == PaymentConfirmStatus.failure) { } else if (state.status == PaymentConfirmStatus.failure) {
ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).hideCurrentSnackBar();

View File

@@ -17,42 +17,63 @@ class PaymentMethodsScreen extends StatelessWidget {
body: Container( body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea( child: SafeArea(
child: Column( child: BlocConsumer<PaymentMethodsBloc, PaymentMethodsState>(
children: [ listener: (context, state) {
const Padding( if (state.status == PaymentMethodsStatus.failure) {
padding: EdgeInsets.symmetric(horizontal: 20), ScaffoldMessenger.of(context).showSnackBar(
child: CustomAppBar(title: 'Способы оплаты'), SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
), );
const SizedBox(height: 24), }
Expanded( },
child: BlocConsumer<PaymentMethodsBloc, PaymentMethodsState>( builder: (context, state) {
listener: (context, state) { final isNetworkProcessing = state.status == PaymentMethodsStatus.loading ||
if (state.status == PaymentMethodsStatus.failure) { (state.isDeleting ?? false) ||
ScaffoldMessenger.of(context).showSnackBar( (state.isSettingMain ?? false);
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
);
}
},
builder: (context, state) {
if (state.status == PaymentMethodsStatus.loading && state.cards.isEmpty) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF00D4AA)));
}
return SingleChildScrollView( return Stack(
padding: const EdgeInsets.symmetric(horizontal: 20), children: [
child: Column( Column(
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ const SizedBox(height: 16),
_buildBalanceCard(context, state.balance), const Padding(
const SizedBox(height: 20), padding: EdgeInsets.symmetric(horizontal: 20),
_buildCardsList(context, state), child: CustomAppBar(title: 'Способы оплаты'),
],
), ),
); const SizedBox(height: 24),
}, Expanded(
), child: state.cards.isEmpty && state.status == PaymentMethodsStatus.loading
), ? const Center(
], child: CircularProgressIndicator(color: Color(0xFF00D4AA)),
)
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBalanceCard(context, state.balance),
const SizedBox(height: 20),
_buildCardsList(context, state),
],
),
),
),
],
),
if (isNetworkProcessing && state.cards.isNotEmpty)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: const Center(
child: CircularProgressIndicator(
color: Color(0xFF00D4AA),
),
),
),
),
],
);
},
), ),
), ),
), ),
@@ -162,7 +183,13 @@ class PaymentMethodsScreen extends StatelessWidget {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () => context.go('/home/payment-methods/add-card'), onTap: () async {
final isCardAdded = await context.push<bool>('/home/payment-methods/add-card');
if (isCardAdded == true && context.mounted) {
context.read<PaymentMethodsBloc>().add(PaymentMethodsStarted());
}
},
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),

View File

@@ -14,6 +14,7 @@ class PrivacyPolicyScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 20), padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: ''), child: CustomAppBar(title: ''),

View File

@@ -50,7 +50,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: SafeArea( child: SafeArea(
child: BlocBuilder<ProfileBloc, ProfileState>( child: BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) { builder: (context, state) {
@@ -70,56 +72,82 @@ class _ProfileScreenState extends State<ProfileScreen> {
} }
final profile = state.profile!; final profile = state.profile!;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 16),
CustomAppBar(title: 'Профиль'),
const SizedBox(height: 32),
Stack(
alignment: Alignment.topRight,
children: [
CircleAvatar( return LayoutBuilder(
radius: 60, builder: (context, constraints) {
backgroundColor: AppColors.checkboxFill, return SingleChildScrollView(
backgroundImage: (profile.avatarUrl != null && profile.avatarUrl!.isNotEmpty) child: ConstrainedBox(
? NetworkImage("${profile.avatarUrl!}?v=${DateTime.now().minute}") constraints: BoxConstraints(
: null, minHeight: constraints.maxHeight,
child: (profile.avatarUrl == null || profile.avatarUrl!.isEmpty) ),
? Text( child: Padding(
profile.name.isNotEmpty ? profile.name[0].toUpperCase() : '', padding: const EdgeInsets.symmetric(horizontal: 20),
style: const TextStyle(fontSize: 50, color: AppColors.darkBlue), child: Column(
) children: [
: null, const SizedBox(height: 16),
), GestureDetector( CustomAppBar(title: 'Профиль'),
onTap: _pickImage, const SizedBox(height: 32),
child: Container(
margin: const EdgeInsets.only(top: 0, right: 0), Stack(
child: Image.asset( alignment: Alignment.topRight,
'assets/icons/edit.png', children: [
width: 24, CircleAvatar(
height: 24, radius: 60,
backgroundColor: AppColors.checkboxFill,
backgroundImage:
(profile.avatarUrl != null &&
profile.avatarUrl!.isNotEmpty)
? NetworkImage(
"${profile.avatarUrl!}?v=${DateTime.now().minute}",
)
: null,
child:
(profile.avatarUrl == null ||
profile.avatarUrl!.isEmpty)
? Text(
profile.name.isNotEmpty
? profile.name[0].toUpperCase()
: '',
style: const TextStyle(
fontSize: 50,
color: AppColors.darkBlue,
),
)
: null,
),
GestureDetector(
onTap: _pickImage,
child: Container(
child: Image.asset(
'assets/icons/edit.png',
width: 24,
height: 24,
),
),
),
],
), ),
),
const SizedBox(height: 32),
_ProfileInfoBlock(
profile: profile,
onEditTap: () => context.go("/home/profile/edit"),
),
// const SizedBox(height: 24),
// _SettingsBlock(
// notificationsEnabled: notificationsEnabled,
// onNotificationsChanged: (v) =>
// setState(() => notificationsEnabled = v),
// ),
],
), ),
], ),
), ),
const SizedBox(height: 32), );
_ProfileInfoBlock( },
profile: profile,
onEditTap: () => context.go("/home/profile/edit"),
),
const SizedBox(height: 24),
_SettingsBlock(
notificationsEnabled: notificationsEnabled,
onNotificationsChanged: (v) =>
setState(() => notificationsEnabled = v),
),
const SizedBox(height: 24),
],
),
); );
}, },
), ),

View File

@@ -45,6 +45,7 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
CustomAppBar(title: 'Промокоды'), CustomAppBar(title: 'Промокоды'),
const SizedBox(height: 32), const SizedBox(height: 32),

View File

@@ -17,6 +17,7 @@ class QRScanInfoScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: "Сканирование QR-кода"), child: CustomAppBar(title: "Сканирование QR-кода"),

View File

@@ -6,6 +6,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:be_happy/di/service_locator.dart'; import 'package:be_happy/di/service_locator.dart';
import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart'; import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
class QrScanScreen extends StatefulWidget { class QrScanScreen extends StatefulWidget {
@@ -149,15 +150,17 @@ class _QrScanScreenState extends State<QrScanScreen> {
), ),
), ),
// ✅ ИЗМЕНЕНО: прижимаем аппбар к левому краю
SafeArea( SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // ✅ Выравнивание по левому краю
children: [ children: [
const SizedBox(height: 16),
CustomAppBar(title: "Сканирование QR-кода"),
const SizedBox(height: 60), const SizedBox(height: 60),
const Text( const Text(
'Наведите рамку на QR-код — номер будет распознан автоматически', 'Наведите рамку на QR-код — номер будет распознан автоматически',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 16, fontSize: 16,
@@ -170,7 +173,6 @@ class _QrScanScreenState extends State<QrScanScreen> {
], ],
), ),
), ),
),
SafeArea( SafeArea(
child: Align( child: Align(

View File

@@ -58,6 +58,7 @@ class _ScooterCodeInputScreenState extends State<ScooterCodeInputScreen> {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: "Ввод QR-кода"), child: CustomAppBar(title: "Ввод QR-кода"),

View File

@@ -70,6 +70,7 @@ class ScooterDetailScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
CustomAppBar( CustomAppBar(
title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат', title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат',
), ),
@@ -101,6 +102,7 @@ class ScooterDetailScreen extends StatelessWidget {
context.pushReplacement('/home/tarif-sheet', extra: scooter); context.pushReplacement('/home/tarif-sheet', extra: scooter);
}, },
), ),
const SizedBox(height: 30)
], ],
), ),
), ),

View File

@@ -123,6 +123,11 @@ class _SendPhotoViewState extends State<SendPhotoView> {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: "Отправить фото"),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100),
child: Text( child: Text(

View File

@@ -1,13 +1,8 @@
import 'dart:math' as math;
import 'package:be_happy/presentation/event/spalsh_event.dart'; import 'package:be_happy/presentation/event/spalsh_event.dart';
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart'; import 'package:be_happy/presentation/viewmodel/splash_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:shared_preferences/shared_preferences.dart';
// Подключи сюда свои реальные экраны:
import 'phone_screen.dart';
import 'pin_login_screen.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@@ -19,35 +14,61 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final AnimationController _controller; late final AnimationController _controller;
late final Animation<double> _revealAnimation;
static const double logoSize = 300; // Фаза 1: Заполнение цветом слева направо (0.0 -> 0.5)
late final Animation<double> _fillProgress;
// Фаза 2: Укатывание вправо (0.6 -> 1.0)
late final Animation<double> _rollTranslation;
late final Animation<double> _rollRotation;
// Уменьшенный размер логотипа по вашему запросу
static const double logoSize = 130;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// контроллер анимации
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 2500), duration: const Duration(milliseconds: 3000),
); );
// анимация движения "затемняющего" прямоугольника _fillProgress = Tween<double>(begin: 0.0, end: 1.0).animate(
_revealAnimation = Tween<double>(begin: 0.0, end: 1.0).animate( CurvedAnimation(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut), parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
),
); );
// запускаем анимацию _rollTranslation = Tween<double>(begin: 0.0, end: 1.0).animate(
_controller.forward().then((_) async { CurvedAnimation(
// небольшая пауза после анимации parent: _controller,
await Future.delayed(const Duration(milliseconds: 800)); curve: const Interval(0.6, 1.0, curve: Curves.easeInCubic),
if (!mounted) { ),
return; );
}
context.read<SplashBloc>().add(AuthCheckRequested()); _rollRotation = Tween<double>(begin: 0.0, end: 2 * math.pi).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
),
);
// Добавляем задержку перед стартом, чтобы пользователь успел увидеть экран
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
// Ждем 500мс после того, как первый кадр отрисовался
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
// Запускаем анимацию
_controller.forward().then((_) {
if (!mounted) return;
context.read<SplashBloc>().add(AuthCheckRequested());
});
}); });
} }
@override @override
@@ -56,69 +77,102 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose(); super.dispose();
} }
@override
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
final double endTranslation = screenWidth / 2 + logoSize;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF3A3A3A), body: Stack(
body: Center( children: [
child: AnimatedBuilder( Positioned.fill(
animation: _controller, child: Container(
builder: (context, _) { decoration: const BoxDecoration(
final double offset = _revealAnimation.value * (logoSize * 1.2); gradient: LinearGradient(
begin: Alignment.topCenter,
return Stack( end: Alignment.bottomCenter,
alignment: Alignment.center, colors: [
children: [ Color(0xFF293A69),
// Цветной логотип (на заднем плане) Color(0xFF202741),
Image.asset( ],
'assets/logo_color.png',
width: logoSize,
height: logoSize,
fit: BoxFit.contain,
), ),
),
),
),
// Прямоугольник, который "уезжает" вправо, открывая логотип // 2. Волна
ClipRect( Positioned(
child: Align( top: 0,
alignment: Alignment.centerLeft, left: 0,
child: FractionallySizedBox( right: 0,
widthFactor: 1, child: Image.asset(
child: Container( 'assets/wave.png',
width: logoSize, fit: BoxFit.contain,
height: logoSize, ),
color: const Color(0xFF3A3A3A), ),
transform: Matrix4.translationValues(offset, 0, 0),
), Positioned(
bottom: 0,
left: 0,
right: 0,
child: Image.asset(
'assets/splash_map.png',
fit: BoxFit.contain,
),
),
Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final double translationX = _rollTranslation.value * endTranslation;
return Transform(
transform: Matrix4.translationValues(translationX, 0, 0)
..rotateZ(_rollRotation.value),
alignment: Alignment.center,
// Используем ShaderMask для эффекта заполнения/проявления
child: ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
// Используем _fillProgress для сдвига жесткой границы градиента
colors: const [Colors.white, Colors.transparent],
stops: [_fillProgress.value, _fillProgress.value],
).createShader(bounds);
},
blendMode: BlendMode.dstIn, // Оставляет только ту часть логотипа, где градиент белый
child: Image.asset(
'assets/splash_logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.contain,
), ),
), ),
), );
},
// Обводка логотипа (поверх) ),
Image.asset(
'assets/logo_outline.png',
width: logoSize * 1.01,
height: logoSize * 1.01,
fit: BoxFit.contain,
),
],
);
},
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Text(
'Версия приложения 1.0',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
), ),
),
// 5. Версия приложения
Positioned(
left: 0,
right: 0,
bottom: 24,
child: Text(
'Версия приложения 1.0',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 12,
fontWeight: FontWeight.w300,
letterSpacing: 0.5,
),
),
),
],
), ),
); );
} }
}
}

View File

@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/app_checkbox.dart'; import '../components/app_checkbox.dart';
import '../components/custom_app_bar.dart'; // ✅ Добавь импорт
import '../components/gradient_button.dart'; import '../components/gradient_button.dart';
import '../components/period_selector.dart'; import '../components/period_selector.dart';
import '../event/subscription_details_event.dart'; import '../event/subscription_details_event.dart';
@@ -17,66 +19,91 @@ class SubscriptionDetailsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF1A2355), body: Container(
appBar: AppBar( decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
backgroundColor: Colors.transparent, child: SafeArea(
elevation: 0, child: Column(
leading: IconButton( children: [
icon: const Icon(Icons.arrow_back_ios, color: Colors.white), const SizedBox(height: 16),
onPressed: () => context.pop(), Padding(
), padding: const EdgeInsets.symmetric(horizontal: 20),
title: BlocBuilder<SubscriptionDetailsBloc, SubscriptionDetailsState>( child:
builder: (context, state) { BlocConsumer<
if (state is DetailsContentState) { SubscriptionDetailsBloc,
return Text(state.subscription.title); SubscriptionDetailsState
} >(
return const Text("Загрузка..."); listenWhen: (previous, current) =>
}, current is DetailsContentState && current.isSuccess,
listener: (context, state) {
if (state is DetailsContentState && state.isSuccess) {
context.pop(true);
}
},
builder: (context, state) {
String title = "Загрузка...";
if (state is DetailsContentState) {
title = state.subscription.title;
}
return CustomAppBar(title: title);
},
),
),
const SizedBox(height: 20),
// 🔹 Контент
Expanded(
child: Stack(
children: [
// Волна снизу
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Opacity(
opacity: 0.5,
child: Image.asset('assets/wave.png'),
),
),
BlocBuilder<
SubscriptionDetailsBloc,
SubscriptionDetailsState
>(
builder: (context, state) {
if (state is DetailsLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF80FFD1),
),
);
}
if (state is DetailsError) {
return Center(
child: Text(
state.message,
style: const TextStyle(color: Colors.white),
),
);
}
if (state is DetailsContentState) {
return _buildContent(context, state);
}
return const SizedBox();
},
),
],
),
),
],
),
), ),
), ),
body: Stack(
children: [
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Opacity(
opacity: 0.5,
child: Image.asset('assets/wave.png'),
),
),
BlocBuilder<SubscriptionDetailsBloc, SubscriptionDetailsState>(
builder: (context, state) {
if (state is DetailsLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF80FFD1)),
);
}
if (state is DetailsError) {
return Center(
child: Text(
state.message,
style: const TextStyle(color: Colors.white),
),
);
}
if (state is DetailsContentState) {
return _buildContent(context, state);
}
return const SizedBox();
},
),
],
)
); );
} }
Widget _buildContent(BuildContext context, DetailsContentState state) { Widget _buildContent(BuildContext context, DetailsContentState state) {
final bool isAvailableForPurchase = state.subscription.isActive;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -88,22 +115,29 @@ class SubscriptionDetailsScreen extends StatelessWidget {
height: 1.5, height: 1.5,
), ),
), ),
const SizedBox(height: 30),
_ActionCard(state: state), if (isAvailableForPurchase) ...[
const SizedBox(height: 30),
const SizedBox(height: 30), _ActionCard(state: state),
GradientButton( const SizedBox(height: 30),
text: 'Активировать',
onTap: () => context.read<SubscriptionDetailsBloc>().add( GradientButton(
ActivateSubscriptionPressed(), text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать',
onTap: () {
context.read<SubscriptionDetailsBloc>().add(
ActivateSubscriptionPressed(),
);
},
enabled: state.isAgreed,
width: double.infinity,
height: 56,
fontSize: 16,
showArrows: true,
), ),
width: double.infinity, ],
height: 56, const SizedBox(height: 20),
fontSize: 16,
showArrows: true,
),
], ],
), ),
); );
@@ -125,9 +159,6 @@ class _ActionCard extends StatelessWidget {
state.selectedPeriod, state.selectedPeriod,
); );
context.read<SubscriptionDetailsBloc>().add(
SelectPeriodEvent(state.subscription.options[selectedIndex]));
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -205,7 +236,7 @@ class _PriceRow extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Image.asset("assets/icons/money_icon.png", width: 72, height: 72), Image.asset("assets/icons/money_icon.png", width: 72, height: 72),
SizedBox(width: 15), const SizedBox(width: 15),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -225,4 +256,3 @@ class _PriceRow extends StatelessWidget {
); );
} }
} }

View File

@@ -1,3 +1,4 @@
import 'package:be_happy/presentation/event/subscription_list_event.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';
@@ -21,6 +22,7 @@ class SubscriptionsListScreen extends StatelessWidget {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Абонементы'), child: CustomAppBar(title: 'Абонементы'),
@@ -43,17 +45,26 @@ class SubscriptionsListScreen extends StatelessWidget {
if (state is SubscriptionsLoading) { if (state is SubscriptionsLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} else if (state is SubscriptionsLoaded) { } else if (state is SubscriptionsLoaded) {
final activeIds = state.activeSubscriptions.map((e) => e.id).toSet(); final clientSubsMap = {
for (var sub in state.activeSubscriptions) sub.subscriptionId: sub
};
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.only(top: 20), padding: const EdgeInsets.only(top: 20),
itemCount: state.subscriptions.length, itemCount: state.subscriptions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final subscription = state.subscriptions[index]; final subscription = state.subscriptions[index];
final bool isActive = activeIds.contains(subscription.id);
final bool isPurchased = subscription.isCurrent;
final clientSub = clientSubsMap[subscription.id];
final DateTime? expirationDate = isPurchased ? clientSub?.expiredAt : null;
return SubscriptionCard( return SubscriptionCard(
subscription: subscription, subscription: subscription,
isActive: isActive, isActive: isPurchased,
expiredAt: expirationDate,
onRefresh: () { context.read<SubscriptionListBloc>().add(LoadSubscriptionsEvent());},
); );
}, },
); );

View File

@@ -21,53 +21,55 @@ class TopUpScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF1A234E), body: Container(
body: SafeArea( decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: Padding( child: SafeArea(
padding: const EdgeInsets.symmetric(horizontal: 20), child: Padding(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 20),
children: [ child: Column(
const SizedBox(height: 16), children: [
const CustomAppBar(title: 'Пополнение баланса'), const SizedBox(height: 16),
const SizedBox(height: 20), const CustomAppBar(title: 'Пополнение баланса'),
const SizedBox(height: 20),
BlocBuilder<TopUpBloc, TopUpState>( BlocBuilder<TopUpBloc, TopUpState>(
builder: (context, state) { builder: (context, state) {
if (state.isLoading) { if (state.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Expanded( return Expanded(
child: Stack( child: Stack(
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTariffList(state, context), _buildTariffList(state, context),
const SizedBox(height: 30), const SizedBox(height: 30),
_buildPriceInfo(state), _buildPriceInfo(state),
const SizedBox(height: 40), const SizedBox(height: 40),
const Text( const Text(
'Способ оплаты', 'Способ оплаты',
style: TextStyle(color: Colors.white70), style: TextStyle(color: Colors.white70),
), ),
const SizedBox(height: 15), const SizedBox(height: 15),
_buildCardSelector(state, context), _buildCardSelector(state, context),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildAgreement(state, context), _buildAgreement(state, context),
const Spacer(), const Spacer(),
_buildPayButton(state), _buildPayButton(state),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
), ),
], ],
), ),
); );
}, },
), ),
], ],
),
), ),
), )
), ),
); );
} }
@@ -168,7 +170,7 @@ class TopUpScreen extends StatelessWidget {
create: (context) => create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()) PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>())
..add(PaymentMethodSheetStarted()), ..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(), child: const PaymentMethodSheet(showBalance: false,),
), ),
); );

View File

@@ -12,6 +12,8 @@ class ActiveRideState {
final double cost; final double cost;
final bool isPaused; final bool isPaused;
final bool inZone; final bool inZone;
final double latitude;
final double longitude;
const ActiveRideState({ const ActiveRideState({
this.status = ActiveRideStatus.initial, this.status = ActiveRideStatus.initial,
@@ -20,6 +22,8 @@ class ActiveRideState {
this.elapsedTime = Duration.zero, this.elapsedTime = Duration.zero,
this.speed = 0.0, this.speed = 0.0,
this.distance = 0.0, this.distance = 0.0,
this.latitude = 0.0,
this.longitude = 0.0,
this.cost = 0.0, this.cost = 0.0,
this.isPaused = false, this.isPaused = false,
this.inZone = true, this.inZone = true,
@@ -33,6 +37,8 @@ class ActiveRideState {
double? speed, double? speed,
double? distance, double? distance,
double? cost, double? cost,
double? longitude,
double? latitude,
bool? isPaused, bool? isPaused,
bool? inZone, bool? inZone,
}) { }) {
@@ -44,6 +50,8 @@ class ActiveRideState {
speed: speed ?? this.speed, speed: speed ?? this.speed,
distance: distance ?? this.distance, distance: distance ?? this.distance,
cost: cost ?? this.cost, cost: cost ?? this.cost,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isPaused: isPaused ?? this.isPaused, isPaused: isPaused ?? this.isPaused,
inZone: inZone ?? this.inZone, inZone: inZone ?? this.inZone,
); );

View File

@@ -9,6 +9,8 @@ enum ScooterStatus { initial, loading, success, failure }
class ScooterState { class ScooterState {
final List<Scooter> scooters; final List<Scooter> scooters;
final List<Scooter> reservedScooters;
final Scooter? selectedScooterForFocus;
final List<Zone> zones; final List<Zone> zones;
final List<double> area; final List<double> area;
final List<double> areaScooters; final List<double> areaScooters;
@@ -23,6 +25,8 @@ class ScooterState {
ScooterState({ ScooterState({
this.scooters = const [], this.scooters = const [],
this.reservedScooters = const [],
this.selectedScooterForFocus,
this.zones = const [], this.zones = const [],
this.area = const [], this.area = const [],
this.areaScooters = const [], this.areaScooters = const [],
@@ -38,6 +42,8 @@ class ScooterState {
ScooterState copyWith({ ScooterState copyWith({
List<Scooter>? scooters, List<Scooter>? scooters,
List<Scooter>? reservedScooters,
Scooter? selectedScooterForFocus,
List<Zone>? zones, List<Zone>? zones,
List<double>? area, List<double>? area,
List<double>? areaScooters, List<double>? areaScooters,
@@ -52,6 +58,8 @@ class ScooterState {
}) { }) {
return ScooterState( return ScooterState(
scooters: scooters ?? this.scooters, scooters: scooters ?? this.scooters,
reservedScooters: reservedScooters ?? this.reservedScooters,
selectedScooterForFocus: selectedScooterForFocus ?? this.selectedScooterForFocus,
zones: zones ?? this.zones, zones: zones ?? this.zones,
area: area ?? this.area, area: area ?? this.area,
areaScooters: areaScooters ?? this.areaScooters, areaScooters: areaScooters ?? this.areaScooters,

View File

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

View File

@@ -1,3 +1,5 @@
import 'package:be_happy/domain/entities/client_subscription.dart';
import '../../domain/entities/subscription.dart'; import '../../domain/entities/subscription.dart';
abstract class SubscriptionState {} abstract class SubscriptionState {}
@@ -6,7 +8,7 @@ class SubscriptionsLoading extends SubscriptionState {}
class SubscriptionsLoaded extends SubscriptionState { class SubscriptionsLoaded extends SubscriptionState {
final List<Subscription> subscriptions; final List<Subscription> subscriptions;
final List<Subscription> activeSubscriptions; final List<ClientSubscription> activeSubscriptions;
SubscriptionsLoaded({ SubscriptionsLoaded({
required this.subscriptions, required this.subscriptions,

View File

@@ -15,21 +15,29 @@ class DetailsContentState extends SubscriptionDetailsState {
final Subscription subscription; final Subscription subscription;
final SubscriptionPeriod selectedPeriod; final SubscriptionPeriod selectedPeriod;
final bool isAgreed; final bool isAgreed;
final bool isAlreadyPurchased; // ✅ Куплена ли эта подписка сейчас
final bool isSuccess; // ✅ Сигнал для навигатора назад
DetailsContentState({ DetailsContentState({
required this.subscription, required this.subscription,
required this.selectedPeriod, required this.selectedPeriod,
this.isAgreed = false, this.isAgreed = false,
this.isAlreadyPurchased = false,
this.isSuccess = false,
}); });
DetailsContentState copyWith({ DetailsContentState copyWith({
SubscriptionPeriod? selectedPeriod, SubscriptionPeriod? selectedPeriod,
bool? isAgreed, bool? isAgreed,
bool? isAlreadyPurchased,
bool? isSuccess,
}) { }) {
return DetailsContentState( return DetailsContentState(
subscription: this.subscription, subscription: this.subscription,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
isAgreed: isAgreed ?? this.isAgreed, isAgreed: isAgreed ?? this.isAgreed,
isAlreadyPurchased: isAlreadyPurchased ?? this.isAlreadyPurchased,
isSuccess: isSuccess ?? this.isSuccess,
); );
} }
} }

View File

@@ -76,6 +76,8 @@ class ActiveRideBloc extends Bloc<ActiveRideEvent, ActiveRideState> {
cost: orderData?.price ?? 0.0, cost: orderData?.price ?? 0.0,
isPaused: isPaused, isPaused: isPaused,
inZone: orderData?.zone, inZone: orderData?.zone,
latitude: orderData?.latitude,
longitude: orderData?.longitude,
)); ));
_syncTimer?.cancel(); _syncTimer?.cancel();
@@ -196,6 +198,8 @@ class ActiveRideBloc extends Bloc<ActiveRideEvent, ActiveRideState> {
cost: orderData?.price ?? state.cost, cost: orderData?.price ?? state.cost,
isPaused: isPaused, isPaused: isPaused,
inZone: orderData?.zone, inZone: orderData?.zone,
latitude: orderData?.latitude,
longitude: orderData?.longitude,
)); ));
} }
print("CURRENT STATE $state"); print("CURRENT STATE $state");

View File

@@ -5,6 +5,7 @@ import 'package:be_happy/domain/entities/client_notification.dart';
import 'package:be_happy/domain/entities/map_settings.dart'; import 'package:be_happy/domain/entities/map_settings.dart';
import 'package:be_happy/domain/usecase/check_user_usecase.dart'; import 'package:be_happy/domain/usecase/check_user_usecase.dart';
import 'package:be_happy/domain/usecase/get_available_zones_usecase.dart'; import 'package:be_happy/domain/usecase/get_available_zones_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_map_settings_usecase.dart';
import 'package:be_happy/domain/usecase/get_notifications_stream_usecase.dart'; import 'package:be_happy/domain/usecase/get_notifications_stream_usecase.dart';
import 'package:be_happy/domain/usecase/logout_usecase.dart'; import 'package:be_happy/domain/usecase/logout_usecase.dart';
@@ -13,6 +14,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/point.dart'; import '../../domain/entities/point.dart';
import '../../domain/entities/scooter.dart'; import '../../domain/entities/scooter.dart';
import '../../domain/entities/scooter_order.dart';
import '../../domain/entities/zone.dart'; import '../../domain/entities/zone.dart';
import '../../domain/usecase/get_available_scooters_usecase.dart'; import '../../domain/usecase/get_available_scooters_usecase.dart';
import '../../domain/usecase/get_profile_usecase.dart'; import '../../domain/usecase/get_profile_usecase.dart';
@@ -23,6 +25,7 @@ import 'package:maps_toolkit/maps_toolkit.dart' as mt;
class MapBloc extends Bloc<ScooterEvent, ScooterState> { class MapBloc extends Bloc<ScooterEvent, ScooterState> {
final GetAvailableScootersUsecase getScootersUsecase; final GetAvailableScootersUsecase getScootersUsecase;
final GetClientOrdersUsecase getClientOrdersUsecase;
final GetAvailableZonesUsecase getAvailableZonesUsecase; final GetAvailableZonesUsecase getAvailableZonesUsecase;
final GetMapSettingsUsecase getMapSettingsUsecase; final GetMapSettingsUsecase getMapSettingsUsecase;
final GetNotificationsStreamUseCase getNotificationsStreamUseCase; final GetNotificationsStreamUseCase getNotificationsStreamUseCase;
@@ -35,6 +38,7 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
MapBloc( MapBloc(
this.getAvailableZonesUsecase, this.getAvailableZonesUsecase,
this.getScootersUsecase, this.getScootersUsecase,
this.getClientOrdersUsecase,
this.getMapSettingsUsecase, this.getMapSettingsUsecase,
this.getNotificationsStreamUseCase, this.getNotificationsStreamUseCase,
this.getProfileUseCase, this.getProfileUseCase,
@@ -49,6 +53,9 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
on<FetchProfileData>(_onFetchProfileData); on<FetchProfileData>(_onFetchProfileData);
on<CheckUser>(_onCheckUser); on<CheckUser>(_onCheckUser);
on<LogoutPressed>(_onLogoutPressed); on<LogoutPressed>(_onLogoutPressed);
on<FocusOnScooter>(_onFocusOnScooter);
on<ClearMapPlacemarks>(_onClearMapPlacemarks);
on<ClearMapFocus>(_onClearMapFocus);
} }
void startNotificationStream() { void startNotificationStream() {
@@ -97,11 +104,15 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
getScootersUsecase(event.areaScooters, 0, 100), getScootersUsecase(event.areaScooters, 0, 100),
getAvailableZonesUsecase(event.area, 0, 100), getAvailableZonesUsecase(event.area, 0, 100),
getMapSettingsUsecase(), getMapSettingsUsecase(),
getClientOrdersUsecase(),
]); ]);
final scooters = results[0] as List<Scooter>; final scooters = results[0] as List<Scooter>;
final zones = results[1] as List<Zone>; final zones = results[1] as List<Zone>;
final settings = results[2] as MapSettings; final settings = results[2] as MapSettings;
final orders = results[3];
zones.forEach(print); zones.forEach(print);
@@ -127,6 +138,7 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
state.copyWith( state.copyWith(
status: ScooterStatus.success, status: ScooterStatus.success,
scooters: scooters, scooters: scooters,
// reservedScooters: reservedScooters,
zones: filteredZones, zones: filteredZones,
area: event.area, area: event.area,
areaScooters: event.areaScooters, areaScooters: event.areaScooters,
@@ -236,7 +248,9 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
try { try {
final profile = await getProfileUseCase(); final profile = await getProfileUseCase();
emit(state.copyWith(phoneNumber: profile.phone, balance: profile.balance)); emit(
state.copyWith(phoneNumber: profile.phone, balance: profile.balance),
);
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
@@ -280,4 +294,52 @@ class MapBloc extends Bloc<ScooterEvent, ScooterState> {
); );
} }
} }
FutureOr<void> _onFocusOnScooter(FocusOnScooter event, Emitter<ScooterState> emit) {
final updatedReserved = List<Scooter>.from(state.reservedScooters ?? []);
if (!updatedReserved.any((s) => s.id == event.scooter.id)) {
updatedReserved.add(event.scooter);
}
emit(state.copyWith(
selectedScooterForFocus: event.scooter,
reservedScooters: updatedReserved,
));
}
FutureOr<void> _onClearMapPlacemarks(ClearMapPlacemarks event, Emitter<ScooterState> emit) async {
try{
final orders = await getClientOrdersUsecase();
List<Scooter> updatedReservedScooters = [];
if (orders is Success<List<ScooterOrder>>) {
print("FETCH: orders.data.length = ${orders.data?.length}");
updatedReservedScooters = orders.data?.map((order) {
return order.scooter;
}).toList() ?? [];
}
emit(state.copyWith(
reservedScooters: updatedReservedScooters,
));
} catch (e) {
print("Error in _onClearMapPlacemarks: $e");
emit(state.copyWith(
status: ScooterStatus.failure,
errorMessage: e.toString(),
));
}
}
FutureOr<void> _onClearMapFocus(ClearMapFocus event, Emitter<ScooterState> emit) {
emit(state.copyWith(
selectedScooterForFocus: null,
));
}
} }

View File

@@ -0,0 +1,34 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:developer' as dev;
import '../../domain/usecase/get_notifications_usecase.dart';
import '../event/notifications_event.dart';
import '../state/notifications_state.dart';
class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
final GetNotificationsUsecase _getNotificationsUsecase;
NotificationsBloc(this._getNotificationsUsecase) : super(const NotificationsState()) {
on<NotificationsFetchRequested>(_onFetchRequested);
}
Future<void> _onFetchRequested(
NotificationsFetchRequested event,
Emitter<NotificationsState> emit,
) async {
emit(state.copyWith(status: NotificationsStatus.loading));
try {
final notifications = await _getNotificationsUsecase();
emit(state.copyWith(
status: NotificationsStatus.success,
notifications: notifications,
));
} catch (e) {
dev.log('NotificationsBloc: Ошибка загрузки: $e');
emit(state.copyWith(
status: NotificationsStatus.failure,
errorMessage: e.toString(),
));
}
}
}

View File

@@ -3,7 +3,6 @@ import 'package:be_happy/domain/entities/scooter_order.dart';
import 'package:be_happy/domain/usecase/get_scooter_order_history_usecase.dart'; import 'package:be_happy/domain/usecase/get_scooter_order_history_usecase.dart';
import 'package:be_happy/core/result.dart'; import 'package:be_happy/core/result.dart';
// 🔹 EVENTS
abstract class OrderHistoryEvent {} abstract class OrderHistoryEvent {}
class OrderHistoryFetchRequested extends OrderHistoryEvent { class OrderHistoryFetchRequested extends OrderHistoryEvent {
@@ -13,7 +12,6 @@ class OrderHistoryFetchRequested extends OrderHistoryEvent {
class OrderHistoryRefreshRequested extends OrderHistoryEvent {} class OrderHistoryRefreshRequested extends OrderHistoryEvent {}
// 🔹 STATES
enum OrderHistoryStatus { initial, loading, success, failure, empty } enum OrderHistoryStatus { initial, loading, success, failure, empty }
class OrderHistoryState { class OrderHistoryState {
@@ -21,14 +19,14 @@ class OrderHistoryState {
final List<ScooterOrder> orders; final List<ScooterOrder> orders;
final String? errorMessage; final String? errorMessage;
final int currentPage; final int currentPage;
final bool hasMore; final bool hasMore; // можно оставить, но всегда будет false
OrderHistoryState({ OrderHistoryState({
required this.status, required this.status,
this.orders = const [], this.orders = const [],
this.errorMessage, this.errorMessage,
this.currentPage = 1, this.currentPage = 1,
this.hasMore = true, this.hasMore = false, // ✅ По умолчанию — нет больше данных
}); });
OrderHistoryState copyWith({ OrderHistoryState copyWith({
@@ -51,7 +49,8 @@ class OrderHistoryState {
class OrderHistoryBloc extends Bloc<OrderHistoryEvent, OrderHistoryState> { class OrderHistoryBloc extends Bloc<OrderHistoryEvent, OrderHistoryState> {
final GetScooterOrderHistoryUsecase _usecase; final GetScooterOrderHistoryUsecase _usecase;
OrderHistoryBloc(this._usecase) : super(OrderHistoryState(status: OrderHistoryStatus.initial)) { OrderHistoryBloc(this._usecase)
: super(OrderHistoryState(status: OrderHistoryStatus.initial)) {
on<OrderHistoryFetchRequested>(_onFetchRequested); on<OrderHistoryFetchRequested>(_onFetchRequested);
on<OrderHistoryRefreshRequested>(_onRefreshRequested); on<OrderHistoryRefreshRequested>(_onRefreshRequested);
} }
@@ -64,9 +63,11 @@ class OrderHistoryBloc extends Bloc<OrderHistoryEvent, OrderHistoryState> {
emit(state.copyWith(status: OrderHistoryStatus.loading)); emit(state.copyWith(status: OrderHistoryStatus.loading));
} }
final result = await _usecase.call(page: event.page); final result = await _usecase.call(
page: event.page,
pageSize: 1000,
);
// ✅ Явная проверка с правильным типом
if (result is Success<List<ScooterOrder>>) { if (result is Success<List<ScooterOrder>>) {
final orders = result.data; final orders = result.data;
@@ -84,12 +85,10 @@ class OrderHistoryBloc extends Bloc<OrderHistoryEvent, OrderHistoryState> {
status: OrderHistoryStatus.success, status: OrderHistoryStatus.success,
orders: newOrders, orders: newOrders,
currentPage: event.page, currentPage: event.page,
hasMore: orders.length == 20, hasMore: false,
)); ));
} }
} } else if (result is Failure<List<ScooterOrder>>) {
// ✅ Явно указываем тип Failure
else if (result is Failure<List<ScooterOrder>>) {
emit(state.copyWith( emit(state.copyWith(
status: OrderHistoryStatus.failure, status: OrderHistoryStatus.failure,
errorMessage: result.failure.message ?? 'Неизвестная ошибка', errorMessage: result.failure.message ?? 'Неизвестная ошибка',

View File

@@ -89,12 +89,11 @@ class PaymentConfirmBloc
event.isBalance, event.isBalance,
); );
if (result is Success<ScooterOrder>) { if (result is Success<void>) {
emit( emit(
state.copyWith( state.copyWith(
status: PaymentConfirmStatus.success, status: PaymentConfirmStatus.success,
paymentCompleted: true, paymentCompleted: true,
), ),
); );
} else if (result is Failure) { } else if (result is Failure) {

View File

@@ -70,13 +70,16 @@ class PaymentMethodsBloc extends Bloc<PaymentMethodsEvent, PaymentMethodsState>
PaymentMethodsDeleteCard event, PaymentMethodsDeleteCard event,
Emitter<PaymentMethodsState> emit, Emitter<PaymentMethodsState> emit,
) async { ) async {
emit(state.copyWith(isDeleting: true)); emit(state.copyWith(
status: PaymentMethodsStatus.loading,
isDeleting: true,
));
try { try {
final result = await _removePaymentCardUsecase(event.cardId); final result = await _removePaymentCardUsecase(event.cardId);
if (result is Success) { if (result is Success) {
emit(state.copyWith(isDeleting: false)); emit(state.copyWith(isDeleting: false, status: PaymentMethodsStatus.success));
add(PaymentMethodsStarted()); add(PaymentMethodsStarted());
} else if (result is Failure) { } else if (result is Failure) {
String errorMessage = 'Не удалось удалить карту'; String errorMessage = 'Не удалось удалить карту';

View File

@@ -1,4 +1,5 @@
import 'package:be_happy/core/result.dart'; import 'package:be_happy/core/result.dart';
import 'package:be_happy/domain/entities/client_subscription.dart';
import 'package:be_happy/domain/entities/subscription.dart'; import 'package:be_happy/domain/entities/subscription.dart';
import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart'; import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart';
import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart'; import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart';
@@ -11,6 +12,7 @@ class SubscriptionListBloc extends Bloc<SubscriptionEvent, SubscriptionState> {
final GetAvailableSubscriptionsUsecase getAvailableSubscriptionsUsecase; final GetAvailableSubscriptionsUsecase getAvailableSubscriptionsUsecase;
final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase; final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase;
SubscriptionListBloc({ SubscriptionListBloc({
required this.getAvailableSubscriptionsUsecase, required this.getAvailableSubscriptionsUsecase,
required this.getClientSubscriptionsUsecase, required this.getClientSubscriptionsUsecase,
@@ -26,10 +28,25 @@ class SubscriptionListBloc extends Bloc<SubscriptionEvent, SubscriptionState> {
final allResult = results[0]; final allResult = results[0];
final activeResult = results[1] ; final activeResult = results[1] ;
if (allResult is Success<List<Subscription>> && activeResult is Success<List<Subscription>>) { if (allResult is Success<List<Subscription>> && activeResult is Success<List<ClientSubscription>>) {
final availableSubs = allResult.data ?? [];
final clientSubs = activeResult.data ?? [];
final Map<int, Subscription> combinedSubsMap = {};
for (var clientSub in clientSubs) {
if (clientSub.subscription != null) {
combinedSubsMap[clientSub.subscriptionId] = clientSub.subscription;
}
}
for (var sub in availableSubs) {
combinedSubsMap[sub.id] = sub;
}
emit(SubscriptionsLoaded( emit(SubscriptionsLoaded(
subscriptions: allResult.data ?? [], subscriptions: combinedSubsMap.values.toList(),
activeSubscriptions: activeResult.data ?? [], activeSubscriptions: clientSubs,
)); ));
} else { } else {
emit(SubscriptionsError("Не удалось загрузить данные из API")); emit(SubscriptionsError("Не удалось загрузить данные из API"));

View File

@@ -4,38 +4,59 @@ import 'package:be_happy/domain/usecase/activate_subscription_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';
import '../../domain/entities/client_subscription.dart';
import '../../domain/usecase/get_client_subscriptions_usecase.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';
class SubscriptionDetailsBloc extends Bloc<SubscriptionDetailsEvent, SubscriptionDetailsState> { class SubscriptionDetailsBloc
extends Bloc<SubscriptionDetailsEvent, SubscriptionDetailsState> {
final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase; final GetSubscriptionByIdUsecase getSubscriptionByIdUsecase;
final ActivateSubscriptionUsecase activateSubscriptionUsecase; final ActivateSubscriptionUsecase activateSubscriptionUsecase;
final GetClientSubscriptionsUsecase getClientSubscriptionsUsecase;
SubscriptionDetailsBloc(this.getSubscriptionByIdUsecase, SubscriptionDetailsBloc(
this.activateSubscriptionUsecase) : super(DetailsLoading()) { this.getSubscriptionByIdUsecase,
this.activateSubscriptionUsecase,
this.getClientSubscriptionsUsecase,
) : 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 result = await getSubscriptionByIdUsecase(event.subscriptionId);
switch (result) { final clientSubsResult = await getClientSubscriptionsUsecase();
bool isPurchased = false;
case Success<Subscription>(): switch (clientSubsResult) {
final sub = result.data; case Success<List<ClientSubscription>>():
isPurchased = clientSubsResult.data?.any(
if (sub == null) return; (element) => element.subscriptionId == event.subscriptionId,
) ?? false;
emit(DetailsContentState( break;
subscription: sub,
selectedPeriod: sub.options.first,
));
case Failure<Subscription>():
emit(DetailsError("Ошибка при запросе данных"));
case Failure<List<ClientSubscription>>():
isPurchased = false;
break;
} }
switch (result) {
case Success<Subscription>():
final sub = result.data;
if (sub == null) return;
emit(
DetailsContentState(
subscription: sub,
selectedPeriod: sub.options.first,
isAlreadyPurchased: isPurchased,
),
);
break;
case Failure<Subscription>():
emit(DetailsError("Ошибка при запросе данных"));
break;
}
} catch (e) { } catch (e) {
emit(DetailsError("Не удалось загрузить данные")); emit(DetailsError("Не удалось загрузить данные"));
} }
@@ -43,7 +64,9 @@ class SubscriptionDetailsBloc extends Bloc<SubscriptionDetailsEvent, Subscriptio
on<SelectPeriodEvent>((event, emit) { on<SelectPeriodEvent>((event, emit) {
if (state is DetailsContentState) { if (state is DetailsContentState) {
emit((state as DetailsContentState).copyWith(selectedPeriod: event.period)); emit(
(state as DetailsContentState).copyWith(selectedPeriod: event.period),
);
} }
}); });
@@ -53,14 +76,23 @@ class SubscriptionDetailsBloc extends Bloc<SubscriptionDetailsEvent, Subscriptio
} }
}); });
on<ActivateSubscriptionPressed>((event, emit) { on<ActivateSubscriptionPressed>((event, emit) async {
switch(state) { switch (state) {
case DetailsContentState contentState: case DetailsContentState contentState:
activateSubscriptionUsecase(contentState.selectedPeriod.id); if (!contentState.isAgreed) {
return;
}
final result = await activateSubscriptionUsecase(contentState.selectedPeriod.id);
if (result is Success) {
emit(contentState.copyWith(isSuccess: true));
} else {
}
break; break;
default: default:
break; break;
} }
}); });
} }
} }

Some files were not shown because too many files have changed in this diff Show More