17 Commits

Author SHA1 Message Date
fcb23e0d5f fix merge conflict 2026-07-16 10:15:10 +03:00
11c00ad2cc add test block 2026-07-15 22:46:42 +03:00
419b54d5dd Merge pull request 'fix payment card change. fix adding a new payment card' (#6) from fix/bug-fix into main
Reviewed-on: #6
2026-07-13 12:47:55 +00:00
a6e8b9d070 fix payment card change. fix adding a new payment card 2026-07-13 15:46:34 +03:00
4a919a5bcc Merge pull request 'fix add new card in bottomsheet' (#5) from fix/bug-fix into main
Reviewed-on: #5
2026-07-13 08:50:59 +00:00
e91f373258 fix add new card in bottomsheet 2026-07-13 11:49:00 +03:00
902c6d27a4 Merge pull request 'fix/bug-fix' (#4) from fix/bug-fix into main
Reviewed-on: #4
2026-07-12 22:53:19 +00:00
98c5a7a1d1 fix 2026-07-13 01:38:13 +03:00
9eda119527 fix 2026-07-08 13:09:20 +03:00
939b5be7d8 Merge pull request 'fix/ios-launch' (#3) from fix/ios-launch into main
Reviewed-on: #3
2026-06-01 15:02:16 +00:00
10c21e4ae3 resolved merge conflicts 2026-06-01 18:00:44 +03:00
406b2bc2dc Merge pull request 'fix/bug-fix' (#1) from fix/bug-fix into main
Reviewed-on: #1
2026-06-01 14:29:39 +00:00
b9da1d3064 fix styles on the add card page, move the parser to a separate file, change the styles of the page of one news item, add a parser for the subscription page, add the input field for the qr code 2026-06-01 17:15:08 +03:00
0b6757e26f create promocode page 2026-06-01 14:35:46 +03:00
134ffdde60 fix functional bugs 2026-05-29 11:40:55 +03:00
591265a7fc fix order history, add CheckUser check before starting the trip, fix dialog for cancel the order, add dialog for finish the order 2026-05-21 18:00:26 +03:00
c996d0847f add notification, fix appbar,fix style subscription 2026-05-21 12:19:06 +03:00
160 changed files with 4251 additions and 2162 deletions

View File

@@ -44,7 +44,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.sparkit.be_happy"
applicationId = "com.sparkit.behappy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26

View File

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

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

View File

@@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true
android.enableJetifier=true
yandexMapkit.variant=full
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
assets/icons/failed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/icons/success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 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

3
devtools_options.yaml Normal file
View File

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

View File

@@ -559,7 +559,7 @@
isa = XCBuildConfiguration;
buildSettings = {
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -616,7 +616,7 @@
isa = XCBuildConfiguration;
buildSettings = {
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
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" : "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"
}
}
{"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"}}

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.

BIN
lib.zip Normal file

Binary file not shown.

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import '../../domain/entities/promo_code_result.dart';
class PromoCodeResponseDto {
final bool success;
final double balance;
PromoCodeResponseDto({
required this.success,
required this.balance,
});
factory PromoCodeResponseDto.fromJson(Map<String, dynamic> json) {
return PromoCodeResponseDto(
success: json['success'] as bool,
balance: (json['balance'] as num).toDouble(),
);
}
PromoCodeResult toEntity() {
return PromoCodeResult(
success: success,
balance: balance,
);
}
}

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/flutter_client_sse.dart';
import '../../domain/entities/client_subscription.dart';
import '../../domain/entities/point.dart';
import '../../domain/entities/user_profile.dart';
import '../../domain/entities/payment_card.dart';
@@ -51,7 +52,11 @@ class ApiService {
try {
final response = await _dio.post(
"$baseUrl/auth/client/login",
data: {"phone": phone, "model": model, "systemId": systemId},
data: {
"phone": phone,
"model": model,
"systemId": systemId
},
);
if (response.statusCode == 200 || response.statusCode == 201) {
@@ -137,15 +142,17 @@ class ApiService {
if (avatarId != null && profileData["avatar"] != null) {
final String? avatarPath = profileData["avatar"]["path"];
if (avatarPath != null && avatarPath.isNotEmpty) {
avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString(); }
avatarUrl = Uri.parse(fileBaseUrl).resolve(avatarPath).toString();
}
}
dynamic balanceRaw = profileData["balance"];
int? balance;
if (balanceRaw is int) {
balance = balanceRaw;
double? balance;
if (balanceRaw is num) {
balance = balanceRaw.toDouble();
} else if (balanceRaw is String) {
balance = int.tryParse(balanceRaw);
balance = double.tryParse(balanceRaw);
}
return UserProfile(
@@ -175,13 +182,9 @@ class ApiService {
cardsJson.map((cardJson) async {
return PaymentCard(
id: cardJson['id'],
clientId: cardJson['clientId'],
expirationMonth: cardJson['expirationMonth'],
expirationYear: cardJson['expirationYear'],
cardHolder: cardJson['cardHolder'],
bindingId: cardJson['bindingId'],
cardLastNumber: cardJson['cardLastNumber'],
isMain: cardJson['isMain'],
type: cardJson['cardType'],
);
}),
);
@@ -236,11 +239,11 @@ class ApiService {
}
dynamic balanceRaw = data["balance"];
int? balance;
if (balanceRaw is int) {
double? balance;
if (balanceRaw is double) {
balance = balanceRaw;
} else if (balanceRaw is String) {
balance = int.tryParse(balanceRaw);
balance = double.tryParse(balanceRaw);
}
return UserProfile(
@@ -394,19 +397,16 @@ class ApiService {
final url = "$baseUrl/scooter/$title/code";
try {
final response = await _dio.get(
url,
options: await _getAuthOptions(),
);
final response = await _dio.get(url, options: await _getAuthOptions());
if (response.statusCode == 200 || response.statusCode == 201) {
return Scooter.fromJson(response.data);
}
if (response.statusCode == 404) {
throw ScooterNotFoundException(message: "Самокат не найден");
}
return null;
} on DioException catch (e) {
if (e.response?.statusCode == 401) throw UnauthorizedException();
@@ -421,7 +421,7 @@ class ApiService {
int page = 1,
int pageSize = 500,
}) async {
const path = "$baseUrl/zone/available";
const path = "$baseUrl/zone/available2";
final queryParams = {
'readAll': 'true',
@@ -463,7 +463,7 @@ class ApiService {
}
}
Future<List<Subscription>> getClientSubscriptions() async {
Future<List<ClientSubscription>> getClientSubscriptions() async {
const url = "$baseUrl/scootersubscription/client";
try {
@@ -474,14 +474,7 @@ class ApiService {
final List<dynamic> items = responseData['data'] ?? [];
return items.map((item) {
final Map<String, dynamic> subscriptionMap =
Map<String, dynamic>.from(item['subscription'] ?? {});
if (item['expiredAt'] != null) {
subscriptionMap['activeTo'] = item['expiredAt'];
}
return Subscription.fromJson(subscriptionMap);
return ClientSubscription.fromJson(item);
}).toList();
}
return [];
@@ -525,13 +518,13 @@ class ApiService {
}
}
Future<bool> activateSubscription({required int optionId}) async {
Future<bool> activateSubscription({required int optionId, required int cardId}) async {
const url = "$baseUrl/scootersubscription/client";
try {
final response = await _dio.post(
url,
data: {"optionId": optionId},
data: {"optionId": optionId, "cardId": cardId},
options: await _getAuthOptions(),
);
@@ -547,32 +540,27 @@ class ApiService {
}
}
Future<int> addPaymentCard({
required String cardNumber,
required String cardHolder,
required int expirationMonth,
required int expirationYear,
required String cvv,
}) async {
Future<Map<String, dynamic>> addPaymentCard() async {
const url = "$baseUrl/client/card";
final cleanCardNumber = cardNumber.replaceAll(' ', '');
/*final cleanCardNumber = cardNumber.replaceAll(' ', '');*/
try {
final response = await _dio.post(
url,
data: {
/*data: {
"cardNumber": cleanCardNumber,
"cardHolder": cardHolder,
"expirationMonth": expirationMonth,
"expirationYear": expirationYear,
"cvv": cvv,
},
},*/
options: await _getAuthOptions(),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response.data['id'] as int;
return response.data;
}
throw AuthException('Непредвиденный статус: ${response.statusCode}', 0);
} on DioException catch (e) {
final data = e.response?.data;
@@ -674,7 +662,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для начала поездки.");
throw WrongZoneException(
message: "Некорректная зона для начала поездки.",
);
}
}
@@ -705,7 +695,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для начала поездки.");
throw WrongZoneException(
message: "Некорректная зона для начала поездки.",
);
}
}
@@ -717,17 +709,13 @@ class ApiService {
}
}
Future<ScooterOrder?> cancelRide(int orderId) async {
Future<void> cancelRide(int orderId) async {
try {
final response = await _dio.put(
"$baseUrl/scooterorder/$orderId/cancel",
options: await _getAuthOptions(),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
}
return null;
} on DioException catch (e) {
_handleDioError(e);
return null;
@@ -768,7 +756,7 @@ class ApiService {
}
}
Future<ScooterOrder?> finishRide({
Future<void> finishRide({
required int orderId,
required List<int> filesId,
}) async {
@@ -779,10 +767,6 @@ class ApiService {
options: await _getAuthOptions(),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
}
return null;
} on DioException catch (e) {
if (e.response?.statusCode == 400) {
final data = e.response?.data;
@@ -791,7 +775,9 @@ class ApiService {
final firstError = data['message'][0]['message'].toString();
if (firstError.contains("Wrong start zone")) {
throw WrongZoneException(message: "Некорректная зона для завершения поездки.");
throw WrongZoneException(
message: "Некорректная зона для завершения поездки.",
);
}
}
@@ -803,7 +789,7 @@ class ApiService {
}
}
Future<ScooterOrder?> payRide(int orderId) async {
Future<void> payRide(int orderId) async {
try {
final response = await _dio.put(
"$baseUrl/scooterorder/$orderId/pay",
@@ -811,12 +797,12 @@ class ApiService {
);
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
// return ScooterOrder.fromJson(response.data);
return;
}
return null;
} on DioException catch (e) {
_handleDioError(e);
return null;
return;
}
}
@@ -878,7 +864,7 @@ class ApiService {
}
}
Future<ScooterOrder?> payScooterOrderWithPhotos({
Future<void> payScooterOrderWithPhotos({
required int orderId,
required int? cardId,
required bool isBalance,
@@ -891,12 +877,10 @@ class ApiService {
);
if (response.statusCode == 200 || response.statusCode == 201) {
return ScooterOrder.fromJson(response.data);
return;
}
return null;
} on DioException catch (e) {
_handleDioError(e);
return null;
}
}
@@ -918,7 +902,6 @@ class ApiService {
}
}
Future<List<Point>> getScooterOrderRouteHistory({required int id}) async {
try {
final response = await _dio.get(
@@ -930,13 +913,19 @@ class ApiService {
final String routeString = response.data['route'] ?? '[]';
final List<dynamic> routeList = json.decode(routeString);
return routeList.map((item) => Point(
(item[1] as num).toDouble(),
(item[0] as num).toDouble(),
)).toList();
return routeList
.map(
(item) => Point(
(item[1] as num).toDouble(),
(item[0] as num).toDouble(),
),
)
.toList();
}
throw RouteHistoryNotFoundException(message: "История маршрута не найдена");
throw RouteHistoryNotFoundException(
message: "История маршрута не найдена",
);
} on DioException catch (e) {
if (e.response?.statusCode == 401) throw UnauthorizedException();
if (e.response?.statusCode == 403) throw AuthBlockException();
@@ -1017,10 +1006,10 @@ class ApiService {
if (event.data != null && event.data!.isNotEmpty) {
try {
final Map<String, dynamic> jsonData = jsonDecode(event.data!);
print("✅ [SSE] Parsed JSON: $jsonData");
print("[SSE] Parsed JSON: $jsonData");
controller.add(jsonData);
} catch (e) {
print("❌ [SSE] JSON Parse Error: $e | Raw: ${event.data}");
print("[SSE] JSON Parse Error: $e | Raw: ${event.data}");
}
}
},
@@ -1038,6 +1027,42 @@ class ApiService {
return controller.stream;
}
Future<Map<String, dynamic>> getNotifications({
int page = 1,
int pageSize = 10,
}) async {
final url = Uri.parse('$baseUrl/notification/client').replace(
queryParameters: {
'page': page.toString(),
'pageSize': pageSize.toString(),
},
);
final accessToken = await _securityService.getAccessToken();
if (accessToken == null) throw UnauthorizedException();
final response = await http.get(
url,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer $accessToken",
},
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
if (data is Map<String, dynamic>) {
return data;
}
throw Exception('Expected Map with data and pagination');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw AuthBlockException();
}
throw Exception('Ошибка сервера: ${response.statusCode}');
}
Future<List<Certificate>> getCertificates() async {
try {
final response = await _dio.get(
@@ -1097,4 +1122,31 @@ class ApiService {
}
return null;
}
Future<Map<String, dynamic>> applyPromoCode(String code) async {
final token = await _securityService.getAccessToken();
if (token == null) throw Exception('No access token');
final response = await _dio.post(
'$baseUrl/promocode/apply',
data: {'code': code},
options: Options(
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
),
);
if (response.statusCode == 201 || response.statusCode == 200) {
return response.data;
} else if (response.statusCode == 404) {
throw PromoCodeNotFoundException();
} else {
throw Exception('API Error: ${response.statusCode}');
}
}
}
// ✅ Добавь исключение (можно в отдельный файл)
class PromoCodeNotFoundException implements Exception {}

View File

@@ -1,7 +1,9 @@
import 'package:be_happy/domain/entities/client_notification.dart';
import '../../data/network/api_service.dart';
import '../../domain/entities/pagination_meta.dart';
import '../../domain/repositories/notification_repository.dart';
import '../../domain/usecase/get_notifications_usecase.dart';
import '../models/client_notification_dto.dart';
class NotificationRepositoryImpl implements NotificationRepository {
@@ -33,4 +35,30 @@ class NotificationRepositoryImpl implements NotificationRepository {
void closeStream() {
// соединение закрывается автоматически при отписке от stream
}
@override
Future<NotificationsResult> getNotifications({
int page = 1,
int pageSize = 10,
}) async {
try {
final response = await _apiService.getNotifications(
page: page,
pageSize: pageSize,
);
final notifications = (response['data'] as List)
.map((json) => ClientNotificationDto.fromJson(json).toEntity())
.toList();
final pagination = PaginationMeta.fromJson(response['pagination']);
return NotificationsResult(
notifications: notifications,
pagination: pagination,
);
} catch (e, stackTrace) {
throw Exception('Не удалось загрузить уведомления: $e');
}
}
}

View File

@@ -32,26 +32,25 @@ class PaymentRepositoryImpl implements PaymentRepository {
}
@override
Future<Result<void>> addPaymentCard({
required String cardNumber,
Future<Result<Map<String, dynamic>>> addPaymentCard(
/*required String cardNumber,
required String cardHolder,
required String expiryMonth,
required String expiryYear,
required String cvv,
}) async {
required String cvv,*/
) async {
try {
final cardId = await apiService.addPaymentCard(
cardNumber: cardNumber,
final result = await apiService.addPaymentCard(
/*cardNumber: cardNumber,
cardHolder: cardHolder,
expirationMonth: int.parse(expiryMonth),
expirationYear: int.parse(expiryYear),
cvv: cvv,
cvv: cvv,*/
);
// Сохраняем полный номер карты локально
await securityService.saveCardFullNumber(cardId, cardNumber);
// await securityService.saveCardFullNumber(cardId, cardNumber);
return Success(null);
return Success(result);
} on AuthException catch (e) {
return Failure(AuthFailure(e.attemptsLeft));
} on AuthBlockException catch (_) {
@@ -99,9 +98,9 @@ class PaymentRepositoryImpl implements PaymentRepository {
}
@override
Future<Result<bool>> activateSubscription(int optionId) async {
Future<Result<bool>> activateSubscription(int optionId, int cardId) async {
try {
final success = await apiService.activateSubscription(optionId: optionId);
final success = await apiService.activateSubscription(optionId: optionId, cardId: cardId);
return Success(success);
} on AuthException catch (e) {
return Failure(AuthFailure(e.attemptsLeft));

View File

@@ -0,0 +1,23 @@
import '../../domain/entities/promo_code_result.dart';
import '../../domain/repositories/promo_code_repository.dart';
import '../models/promo_code_response_dto.dart';
import '../network/api_service.dart';
class PromoCodeRepositoryImpl implements PromoCodeRepository {
final ApiService apiService;
PromoCodeRepositoryImpl(this.apiService);
@override
Future<PromoCodeResult> applyPromoCode(String code) async {
try {
final data = await apiService.applyPromoCode(code);
final dto = PromoCodeResponseDto.fromJson(data);
return dto.toEntity();
} on PromoCodeNotFoundException {
throw Exception('Промокод не найден');
} catch (e) {
throw Exception('Ошибка активации промокода: $e');
}
}
}

View File

@@ -10,6 +10,7 @@ import 'package:be_happy/domain/repositories/scooter_repository.dart';
import '../../core/failures.dart';
import '../../core/result.dart';
import '../../domain/entities/active_scooter_order.dart';
import '../../domain/entities/client_subscription.dart';
import '../../domain/entities/scooter.dart';
import '../../domain/entities/tariff.dart';
import '../../domain/entities/subscription.dart';
@@ -127,8 +128,8 @@ class ScooterRepositoryImpl extends ScooterRepository {
}
@override
Future<Result<List<Subscription>>> getClientSubscriptions() async {
late final Result<List<Subscription>> result;
Future<Result<List<ClientSubscription>>> getClientSubscriptions() async {
late final Result<List<ClientSubscription>> result;
try {
final subscriptions = await _apiService.getClientSubscriptions();
result = Success(subscriptions);
@@ -191,15 +192,11 @@ class ScooterRepositoryImpl extends ScooterRepository {
}
@override
Future<Result<ScooterOrder>> cancelRide(int orderId) async {
late final Result<ScooterOrder> result;
Future<Result<void>> cancelRide(int orderId) async {
late final Result<void> result;
try {
final order = await _apiService.cancelRide(orderId);
if (order != null) {
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
await _apiService.cancelRide(orderId);
result = Success(null);
} on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) {
@@ -245,18 +242,11 @@ class ScooterRepositoryImpl extends ScooterRepository {
}
@override
Future<Result<ScooterOrder>> finishRide(int orderId, List<int> files) async {
Future<Result<void>> finishRide(int orderId, List<int> files) async {
late final Result<ScooterOrder> result;
try {
final order = await _apiService.finishRide(
orderId: orderId,
filesId: files,
);
if (order != null) {
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
await _apiService.finishRide(orderId: orderId, filesId: files);
result = Success(null);
} on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft));
} on WrongZoneException catch (e) {
@@ -268,15 +258,11 @@ class ScooterRepositoryImpl extends ScooterRepository {
}
@override
Future<Result<ScooterOrder>> payRide(int orderId) async {
late final Result<ScooterOrder> result;
Future<Result<void>> payRide(int orderId) async {
late final Result<void> result;
try {
final order = await _apiService.payRide(orderId);
if (order != null) {
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
await _apiService.payRide(orderId);
result = Success(null);
} on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) {
@@ -335,23 +321,19 @@ class ScooterRepositoryImpl extends ScooterRepository {
}
@override
Future<Result<ScooterOrder>> payScooterOrderWithPhotos({
Future<Result<void>> payScooterOrderWithPhotos({
required int orderId,
required int? cardId,
required bool isBalance,
}) async {
late final Result<ScooterOrder> result;
late final Result<void> result;
try {
final order = await _apiService.payScooterOrderWithPhotos(
orderId: orderId,
cardId: cardId,
isBalance: isBalance,
);
if (order != null) {
result = Success(order);
} else {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
result = Success(null);
} on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft));
} catch (e) {

View File

@@ -85,13 +85,17 @@ import '../data/repositories/auth_repository_impl.dart';
import '../data/repositories/news_repository_impl.dart';
import '../data/repositories/pin_repository_impl.dart';
import '../data/repositories/profile_repository_impl.dart';
import '../data/repositories/promo_code_repository_impl.dart';
import '../data/service/news_api_service.dart';
import '../domain/repositories/auth_repository.dart';
import '../domain/repositories/news_repository.dart';
import '../domain/repositories/promo_code_repository.dart';
import '../domain/service/device_info_service.dart';
import '../domain/usecase/activate_subscription_usecase.dart';
import '../domain/usecase/apply_promo_code_usecase.dart';
import '../domain/usecase/get_client_subscriptions_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_order_history_usecase.dart';
import '../domain/usecase/remove_payment_card_usecase.dart';
@@ -100,7 +104,9 @@ import '../presentation/viewmodel/auth_bloc.dart';
import '../presentation/viewmodel/edit_profile_bloc.dart';
import '../presentation/viewmodel/map_bloc.dart';
import '../presentation/viewmodel/news_bloc.dart';
import '../presentation/viewmodel/notifications_bloc.dart';
import '../presentation/viewmodel/order_history_bloc.dart';
import '../presentation/viewmodel/promo_code_bloc.dart';
import '../presentation/viewmodel/scooter_detail_modal_bloc.dart';
import '../presentation/viewmodel/subscription_list_bloc.dart';
import '../presentation/viewmodel/verify_code_bloc.dart';
@@ -110,7 +116,7 @@ final getIt = GetIt.instance;
Future<void> setupDependencies() async {
final sharedPreferences = await SharedPreferences.getInstance();
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());
// HTTP
getIt.registerSingleton<http.Client>(http.Client());
@@ -286,15 +292,22 @@ Future<void> setupDependencies() async {
getIt.registerSingleton<GetScooterByTitleUsecase>(
GetScooterByTitleUsecase(getIt()),
);
getIt.registerSingleton<GetNotificationsUsecase>(
GetNotificationsUsecase(getIt<NotificationRepository>()),
);
getIt.registerFactory<NotificationsBloc>(
() => NotificationsBloc(getIt<GetNotificationsUsecase>()),
);
// Blocs
getIt.registerLazySingleton<SplashBloc>(() => SplashBloc(getIt()));
getIt.registerFactory<PhoneAuthBloc>(() => PhoneAuthBloc(getIt()));
getIt.registerFactory<VerifyCodeBloc>(() => VerifyCodeBloc(getIt()));
getIt.registerFactory<VerifyCodeBloc>(() => VerifyCodeBloc(getIt(), getIt(), getIt()));
getIt.registerFactory<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt()));
getIt.registerLazySingleton<ProfileBloc>(() => ProfileBloc(getIt(), getIt(), getIt()));
getIt.registerFactory<EditProfileBloc>(
() => EditProfileBloc(getIt(), getIt()),
@@ -310,6 +323,7 @@ Future<void> setupDependencies() async {
getIt(),
getIt(),
getIt(),
getIt(),
),
);
@@ -323,7 +337,7 @@ Future<void> setupDependencies() async {
getIt.registerFactory<PaymentMethodSheetBloc>(
() => PaymentMethodSheetBloc(getIt()),
() => PaymentMethodSheetBloc(getIt(), getIt(), getIt(), getIt()),
);
getIt.registerFactory<CurrentRidesBloc>(() => CurrentRidesBloc(getIt()));
@@ -373,4 +387,20 @@ Future<void> setupDependencies() async {
getIt.registerFactory<ScooterCodeBloc>(
() => ScooterCodeBloc(getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>()),
);
// Repository
getIt.registerSingleton<PromoCodeRepository>(
PromoCodeRepositoryImpl(getIt<ApiService>()),
);
// UseCase
getIt.registerSingleton<ApplyPromoCodeUsecase>(
ApplyPromoCodeUsecase(getIt<PromoCodeRepository>()),
);
// Bloc (factory, т.к. экран создаёт новый экземпляр)
getIt.registerFactory<PromoCodeBloc>(
() => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
);
}

View File

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

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

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

View File

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

View File

@@ -0,0 +1,9 @@
class PromoCodeResult {
final bool success;
final double balance;
PromoCodeResult({
required this.success,
required this.balance,
});
}

View File

@@ -43,8 +43,9 @@ class Scooter {
);
}
@override
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 {
final int id;
final int scooterId;
final Scooter? scooter;
final Scooter scooter;
final int? planId;
final ScooterPlan? plan;
final int clientId;
@@ -29,11 +29,14 @@ class ScooterOrder {
final String? cancelDescription;
final double mileage; //эту
final double avgSpeed; //эту и снизу еще 4 есть
final String payTotalPricePrint;
final String unpayTotalPricePrint;
final String totalOrderPricePrint;
ScooterOrder({
required this.id,
required this.scooterId,
this.scooter,
required this.scooter,
this.planId,
this.plan,
required this.clientId,
@@ -57,15 +60,20 @@ class ScooterOrder {
this.expiresAt,
this.cancelAt,
this.cancelDescription,
required this.mileage, //эту
required this.avgSpeed, //эту и снизу еще 2 есть
required this.mileage,
required this.avgSpeed,
required this.payTotalPricePrint, // оплачено
required this.unpayTotalPricePrint, //неоплачено
required this.totalOrderPricePrint, //общая
});
factory ScooterOrder.fromJson(Map<String, dynamic> json) {
final paymentInfo = json['paymentInfo'] as Map<String, dynamic>?;
return ScooterOrder(
id: json['id'] ?? 0,
scooterId: json['scooterId'] ?? 0,
scooter: json['scooter'] != null ? Scooter.fromJson(json['scooter']) : null,
scooter: Scooter.fromJson(json['scooter']),
planId: json['planId'],
plan: json['plan'] != null ? ScooterPlan.fromJson(json['plan']) : null,
clientId: json['clientId'] ?? 0,
@@ -101,8 +109,11 @@ class ScooterOrder {
? DateTime.parse(json['cancelAt'])
: null,
cancelDescription: json['cancelDescription'],
mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0, //эту
avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0, //эту
mileage: (json['mileage'] as num?)?.toDouble() ?? 0.0,
avgSpeed: (json['avgSpeed'] as num?)?.toDouble() ?? 0.0,
payTotalPricePrint: paymentInfo?['payTotalPricePrint'] ?? '',
unpayTotalPricePrint: paymentInfo?['unpayTotalPricePrint'] ?? '',
totalOrderPricePrint: paymentInfo?['totalOrderPricePrint'] ?? '',
);
}

View File

@@ -6,8 +6,10 @@ class Subscription {
final String title;
final String shortDescription;
final String fullDescription;
final String fullDescriptionJson;
final int planId;
final bool isActive;
final bool isCurrent;
final String currency;
final DateTime? activeFrom;
final DateTime? activeTo;
@@ -21,6 +23,7 @@ class Subscription {
required this.title,
required this.shortDescription,
required this.fullDescription,
required this.fullDescriptionJson,
required this.planId,
required this.isActive,
required this.currency,
@@ -28,6 +31,7 @@ class Subscription {
this.activeTo,
required this.createdAt,
required this.updatedAt,
required this.isCurrent,
required this.options,
});
@@ -41,6 +45,7 @@ class Subscription {
title: json['title'] ?? '',
shortDescription: json['shortDescription'] ?? '',
fullDescription: json['fullDescription'] ?? '',
fullDescriptionJson: json['fullDescriptionJson'] ?? '',
planId: json['planId'] ?? 0,
isActive: json['isActive'] ?? false,
currency: currencyData['currency'] ?? 'BYN',
@@ -48,6 +53,7 @@ class Subscription {
activeTo: json['activeTo'] != null ? DateTime.parse(json['activeTo']) : null,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : 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(),
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import '../entities/promo_code_result.dart';
abstract class PromoCodeRepository {
Future<PromoCodeResult> applyPromoCode(String code);
}

View File

@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:be_happy/domain/entities/active_scooter_order.dart';
import '../../core/result.dart';
import '../entities/client_subscription.dart';
import '../entities/point.dart';
import '../entities/scooter.dart';
import '../entities/subscription.dart';
@@ -16,7 +17,7 @@ abstract class ScooterRepository {
Future<Result<List<Tariff>>> getAvailableTariffs(int scooterId);
Future<Result<List<Subscription>>> getAvailableSubscriptions();
Future<Result<Subscription>> getSubscriptionById(int id);
Future<Result<List<Subscription>>> getClientSubscriptions();
Future<Result<List<ClientSubscription>>> getClientSubscriptions();
Future<Result<ScooterOrder>> bookScooter({
required int scooterId,
required int planId,
@@ -26,17 +27,17 @@ abstract class ScooterRepository {
required bool isInsurance,
});
Future<Result<ScooterOrder>> startRide(int orderId);
Future<Result<ScooterOrder>> cancelRide(int orderId);
Future<Result<void>> cancelRide(int orderId);
Future<Result<ScooterOrder>> pauseRide(int orderId);
Future<Result<ScooterOrder>> resumeRide(int orderId);
Future<Result<ScooterOrder>> finishRide(int orderId, List<int> files);
Future<Result<ScooterOrder>> payRide(int orderId);
Future<Result<void>> finishRide(int orderId, List<int> files);
Future<Result<void>> payRide(int orderId);
Future<Result<List<ScooterOrder>>> getClientOrders();
Future<Result<List<int>>> uploadScooterPhotos(List<File> images);
Future<Result<ActiveScooterOrder>> updateScooterOrderData({
required int orderId,
});
Future<Result<ScooterOrder>> payScooterOrderWithPhotos({
Future<Result<void>> payScooterOrderWithPhotos({
required int orderId,
required int? cardId,
required bool isBalance,

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import '../entities/promo_code_result.dart';
import '../repositories/promo_code_repository.dart';
class ApplyPromoCodeUsecase {
final PromoCodeRepository repository;
ApplyPromoCodeUsecase(this.repository);
Future<PromoCodeResult> call(String code) {
return repository.applyPromoCode(code);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,21 @@ import 'di/service_locator.dart';
void main() async {
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();
runApp(const MyApp());
}
@@ -45,6 +57,7 @@ class MyApp extends StatelessWidget {
BlocProvider(create: (context) => getIt<VerifyCodeBloc>()),
BlocProvider(create: (context) => getIt<PinBloc>()),
BlocProvider(create: (context) => getIt<MapBloc>()),
BlocProvider(create: (context) => getIt<ProfileBloc>()),
],
child: BlocListener<SplashBloc, SplashState>(
listener: (context, state) {
@@ -62,4 +75,4 @@ class MyApp extends StatelessWidget {
),
);
}
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as dom;
import 'dart:convert';
/// Парсер контента новостей (поддерживает HTML и JSON-формат)
class ContentParser {
/// Парсит текст новости: сначала проверяет textJson, потом text
static List<Widget> parseContent({String? textJson, String? text}) {
if (textJson != null && textJson.isNotEmpty) {
return _parseJsonText(textJson);
} else if (text != null && text.isNotEmpty) {
return _parseHtmlText(text);
}
return const [];
}
// 🔹 HTML-парсинг
static List<Widget> _parseHtmlText(String htmlText) {
final parsedHtml = html_parser.parse(htmlText);
final List<dom.Node> elements = parsedHtml.body?.nodes ?? [];
return _parseHtmlElements(elements);
}
static List<Widget> _parseHtmlElements(List<dom.Node> nodes) {
List<Widget> widgets = [];
for (final node in nodes) {
if (node is dom.Element) {
final text = node.text.trim();
if (text.isEmpty) continue;
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
widgets.add(_buildHeader(text));
break;
case 'p':
widgets.add(_buildParagraph(text));
break;
case 'ul':
widgets.add(_buildUnorderedList(node.nodes));
break;
default:
widgets.add(_buildDefaultText(text));
}
} else if (node is dom.Text) {
final text = node.text.trim();
if (text.isNotEmpty) {
widgets.add(_buildDefaultText(text));
}
}
}
return widgets;
}
// 🔹 JSON-парсинг
static List<Widget> _parseJsonText(String jsonString) {
try {
final dynamic data = jsonDecode(jsonString);
return _parseJsonNode(data);
} catch (e) {
return [
const Text(
'Ошибка отображения текста новости',
style: TextStyle(color: Colors.red, fontSize: 14),
),
];
}
}
static List<Widget> _parseJsonNode(dynamic node) {
List<Widget> widgets = [];
if (node is List) {
for (final item in node) {
widgets.addAll(_parseJsonNode(item));
}
} else if (node is Map<String, dynamic>) {
final type = node['type'] as String?;
final content = node['content'];
switch (type) {
case 'div':
if (content is List) {
widgets.addAll(_parseJsonNode(content));
}
break;
case 'h1':
case 'h2':
case 'h3':
final text = _extractJsonText(content);
if (text != null && text.isNotEmpty) {
widgets.add(_buildHeader(text));
}
break;
case 'p':
final text = _extractJsonText(content);
if (text != null && text.isNotEmpty) {
widgets.add(_buildParagraph(text));
}
break;
case 'ul':
if (content is List) {
widgets.add(_buildJsonUnorderedList(content));
}
break;
case 'li':
final text = _extractJsonText(content);
if (text != null && text.isNotEmpty) {
widgets.add(_buildListItem(text));
}
break;
default:
final text = _extractJsonText(content);
if (text != null && text.isNotEmpty) {
widgets.add(_buildDefaultText(text));
}
}
}
return widgets;
}
// 🔹 Вспомогательные методы извлечения текста из JSON
static String? _extractJsonText(dynamic content) {
if (content == null) return null;
if (content is List) {
return content.map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).join(' ');
}
return content.toString();
}
// 🔹 Виджеты для отображения (принимают НЕ nullable String)
static Widget _buildHeader(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
text, // ✅ text — не nullable
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
}
static Widget _buildParagraph(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
);
}
static Widget _buildDefaultText(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
);
}
static Widget _buildUnorderedList(List<dom.Node> children) {
final items = <Widget>[];
for (final child in children) {
if (child is dom.Element && child.localName == 'li') {
final text = child.text.trim();
if (text.isNotEmpty) {
items.add(_buildListItem(text));
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items,
),
);
}
static Widget _buildJsonUnorderedList(List<dynamic> content) {
final items = <Widget>[];
for (final item in content) {
if (item is Map<String, dynamic> && item['type'] == 'li') {
final text = _extractJsonText(item['content']);
if (text != null && text.isNotEmpty) {
items.add(_buildListItem(text));
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items,
),
);
}
static Widget _buildListItem(String text) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
)),
Expanded(
child: Text(
text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
),
],
),
);
}
}

View File

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

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

View File

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

View File

@@ -1,13 +1,17 @@
import 'dart:async';
import 'dart:ui';
import 'package:be_happy/domain/entities/scooter.dart';
import 'package:bot_toast/bot_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../di/service_locator.dart';
import '../../event/active_ride_event.dart';
import '../../event/map_event.dart';
import '../../state/active_ride_state.dart';
import '../../viewmodel/active_ride_bloc.dart';
import '../../viewmodel/map_bloc.dart';
import '../dialog/finish_ride_confirmation_dialog.dart';
import '../notification_toast.dart';
class ActiveRideSheet extends StatefulWidget {
@@ -55,7 +59,7 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
return BlocProvider.value(
value: _bloc,
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) {
if (!state.inZone) {
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) {
// Логика отображения загрузки и ошибок остается прежней
@@ -202,8 +215,8 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers',
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'DigitalNumbers',
),
),
const SizedBox(height: 4),
@@ -290,13 +303,23 @@ class _ActiveRideSheetState extends State<ActiveRideSheet> {
child: InkWell(
onTap: state.status == ActiveRideStatus.loading
? null
: () {
_bloc.add(FinishRide(widget.orderId));
Navigator.pop(context);
context.go("/home/order-photos/${widget.orderId}");
},
: () async {
// 🔹 Показываем диалог подтверждения
final result = await showDialog<bool>(
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),
child: Column(
child: Column( // ✅ Вернули Column с иконкой и текстом
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(

View File

@@ -218,12 +218,15 @@ class _RideCardState extends State<_RideCard> {
} else {
displayTime = _elapsedTime;
}
final isExpired = isReserved && (_reservationTime - _elapsedTime).isNegative;
final timeString = _formatDuration(displayTime);
final statusText = _getStatusText(widget.order.status);
final statusColor = _getStatusColor(widget.order.status);
final statusText = widget.order.status == 'Booking' ? 'Забронирован' : "Активный";
final statusColor = widget.order.status == 'Booking' ? Color(0xFFFFCC00) : Color(0xFF8bffaa);
final scooterNumber =
widget.order.scooter?.number ?? widget.order.scooterId.toString();
widget.order.scooter.number ?? widget.order.scooterId.toString();
return Container(
padding: const EdgeInsets.all(16),
@@ -261,7 +264,7 @@ class _RideCardState extends State<_RideCard> {
Text(
statusText,
style: TextStyle(
color: Colors.white,
color: statusColor,
fontSize: 14,
fontWeight: FontWeight.w600,
),
@@ -269,22 +272,51 @@ class _RideCardState extends State<_RideCard> {
],
),
const SizedBox(height: 4),
Text(
scooterNumber,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_getLocationText(),
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 13,
),
Row(
children: [
Image.asset("assets/icons/qr_icon_order.png"),
const SizedBox(width: 6),
Text(
scooterNumber,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
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 +333,10 @@ class _RideCardState extends State<_RideCard> {
timeString,
style: TextStyle(
color: statusColor,
fontSize: 26,
fontSize: 20,
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers',
fontFamily: 'DigitalNumbers',
),
),
],
@@ -318,7 +350,8 @@ class _RideCardState extends State<_RideCard> {
height: 32,
width: 100,
fontSize: 11,
onTap: () {
enabled: !isExpired,
onTap: !isExpired ? () {
if (isReserved) {
Navigator.pop(context);
showModalBottomSheet(
@@ -345,7 +378,7 @@ class _RideCardState extends State<_RideCard> {
),
);
}
},
} : null,
),
),
],
@@ -360,7 +393,7 @@ class _RideCardState extends State<_RideCard> {
switch (status.toLowerCase()) {
case 'reserved':
case 'holding':
return 'Забронировано';
return 'Забронирован';
case 'active':
case 'in_progress':
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: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 {
@@ -19,13 +17,6 @@ class MapSettingsSheet extends StatelessWidget {
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,
@@ -41,9 +32,9 @@ class MapSettingsSheet extends StatelessWidget {
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
),
_SettingItemData(
label: 'Разрешено кататься',
label: 'Парковка запрещена',
icon: Icons.block_outlined,
color: const Color(0xFF5ECD4C),
color: const Color(0xFFF59E0B),
isActive: state.isRestrictedParkingZoneActive,
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';
class PaymentMethodSheet extends StatefulWidget {
final PaymentCard? initialSelectedCard; // Добавляем это поле
final PaymentCard? initialSelectedCard;
final bool showBalance;
const PaymentMethodSheet({
super.key,
this.initialSelectedCard, // Инициализируем в конструкторе
this.initialSelectedCard,
this.showBalance = true,
});
@override
@@ -25,6 +27,7 @@ class PaymentMethodSheet extends StatefulWidget {
class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
int? _selectedPaymentMethod = -2;
int? _processingCardId; // <-- Переменная для отслеживания отправленного запроса
@override
void initState() {
@@ -34,56 +37,20 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
@override
Widget build(BuildContext context) {
return BlocBuilder<PaymentMethodSheetBloc, PaymentMethodSheetState>(
return BlocConsumer<PaymentMethodSheetBloc, PaymentMethodSheetState>(
listener: (context, state) {
if (state.status == PaymentMethodSheetStatus.success && _processingCardId != null) {
final updatedCard = state.cards.cast<PaymentCard?>().firstWhere(
(c) => c?.id == _processingCardId,
orElse: () => state.cards.cast<PaymentCard?>().firstWhere((c) => c?.isMain == true, orElse: () => null),
);
_processingCardId = null;
Navigator.pop(context, updatedCard);
}
},
builder: (context, state) {
if (state.status == PaymentMethodSheetStatus.loading) {
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: 450,
decoration: BoxDecoration(
color: const Color(0x00000032).withOpacity(0.6),
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
),
),
);
}
if (state.status == PaymentMethodSheetStatus.failure) {
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: 450,
decoration: BoxDecoration(
color: const Color(0x00000032).withOpacity(0.6),
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: Center(
child: Text(
state.errorMessage ?? 'Ошибка загрузки карт',
style: const TextStyle(color: Colors.white),
),
),
),
),
),
);
}
if (state.status == PaymentMethodSheetStatus.success && _selectedPaymentMethod == -2) {
if (widget.initialSelectedCard != null) {
final initialIndex = state.cards.indexWhere(
@@ -92,18 +59,10 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
_selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1;
} else {
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
_selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1;
_selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : (widget.showBalance ? -1 : 0);
}
}
// Находим карту с isMain: true при загрузке
/*if (_selectedPaymentMethod == null) {
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
if (mainCardIndex != -1) {
_selectedPaymentMethod = mainCardIndex;
}
}*/
return Align(
alignment: Alignment.bottomCenter,
child: ClipRRect(
@@ -120,47 +79,6 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
@@ -169,19 +87,20 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
PaymentOption(
title: 'Баланс',
subtitle: '${state.balance.toStringAsFixed(2)} BYN',
isSelected: _selectedPaymentMethod == -1,
onTap: () {
setState(() {
_selectedPaymentMethod = -1;
});
Navigator.pop(context, 'balance');
},
),
const SizedBox(height: 12),
if (widget.showBalance) ...[
PaymentOption(
title: 'Баланс',
subtitle: '${state.balance} баллов',
isSelected: _selectedPaymentMethod == -1,
onTap: () {
setState(() {
_selectedPaymentMethod = -1;
});
Navigator.pop(context, 'balance');
},
),
const SizedBox(height: 12),
],
...state.cards.asMap().entries.map((entry) {
final index = entry.key;
@@ -189,14 +108,20 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PaymentOption(
title: card.type,
subtitle: '****${card.cardLastNumber}',
title: card.isMain ? "Основная" : "Карта",
subtitle: '${card.cardLastNumber}',
isSelected: _selectedPaymentMethod == index,
onTap: () {
onTap: () async {
setState(() {
_selectedPaymentMethod = index;
_processingCardId = card.id;
});
Navigator.pop(context, card);
// Отправляем событие в Блок
context.read<PaymentMethodSheetBloc>().add(
SelectPaymentCardRequested(card),
);
},
),
);
@@ -215,8 +140,7 @@ class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.pop(context);
context.go('/home/payment-methods/add-card');
context.read<PaymentMethodSheetBloc>().add(AddPaymentCardRequested());
},
borderRadius: BorderRadius.circular(24),
child: Center(

View File

@@ -14,7 +14,7 @@ class ReservedRideSheet extends StatefulWidget {
final String scooterNumber;
final int orderId;
final Duration initialReservationTime;
const ReservedRideSheet({
super.key,
required this.scooterNumber,
@@ -60,264 +60,286 @@ class _ReservedRideSheetState extends State<ReservedRideSheet> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: 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(
padding: const EdgeInsets.only(top: 20, bottom: 10),
decoration: BoxDecoration(
color: const Color(0xFF000032).withOpacity(0.5),
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 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,
),
),
],
),
child: Scaffold(
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: 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(
padding: const EdgeInsets.only(top: 20, bottom: 10),
decoration: BoxDecoration(
color: const Color(0xFF000032).withOpacity(0.5),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(30),
),
const SizedBox(height: 20),
// ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
// Таймер
Expanded(
flex: 2,
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
// HEADER
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Row(
children: [
// Иконка самоката (ВЫШЕ)
SizedBox(
width: 44,
height: 56,
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
),
Icon(
Icons.arrow_back_ios_sharp,
color: const Color(0x99FFFFFF),
size: 20,
),
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,
),
),
],
),
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(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,
const SizedBox(width: 12),
Expanded(
child: Text(
'Бесплатное бронирование',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
);
} 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: 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,
// ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
// Таймер
Expanded(
flex: 2,
child: Text(
_formatDuration(_reservationTime),
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: 'Digital Numbers',
),
),
),
),
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,
// Иконка и информация (ВЫСОКИЙ БЛОК)
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(
children: [
// Иконка самоката (ВЫШЕ)
SizedBox(
width: 44,
height: 56,
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
),
),
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

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

View File

@@ -11,7 +11,10 @@ import '../../../di/service_locator.dart';
import '../../../domain/entities/payment_card.dart';
import '../../../domain/entities/scooter.dart';
import '../../../domain/entities/tariff.dart';
import '../../../domain/usecase/add_payment_card_usecase.dart';
import '../../../domain/usecase/get_payment_cards_usecase.dart';
import '../../../domain/usecase/get_profile_usecase.dart';
import '../../../domain/usecase/set_main_payment_card_usecase.dart';
import '../../event/payment_method_sheet_event.dart';
import '../../event/tariff_sheet_event.dart';
import '../../state/tariff_sheet_state.dart';
@@ -39,6 +42,24 @@ class _TariffSheetState extends State<TariffSheet> {
@override
Widget build(BuildContext context) {
final double remainingKm = (62 * widget.scooter.batteryLevel) / 100;
final String kmText = remainingKm % 1 == 0
? '${remainingKm.toInt()} км'
: '${remainingKm.toStringAsFixed(1)} км';
final int totalMinutes = ((150 * widget.scooter.batteryLevel) / 100).round();
final int hours = totalMinutes ~/ 60;
final int minutes = totalMinutes % 60;
String timeText = '';
if (hours > 0 && minutes > 0) {
timeText = '$hours ч $minutes мин';
} else if (hours > 0) {
timeText = '$hours ч';
} else {
timeText = '$minutes мин';
}
return BlocConsumer<TariffSheetBloc, TariffSheetState>(
listener: (context, state) {
if (state.status == TariffSheetStatus.failure && state.errorMessage != null) {
@@ -134,7 +155,6 @@ class _TariffSheetState extends State<TariffSheet> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 HEADER
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
@@ -226,7 +246,7 @@ class _TariffSheetState extends State<TariffSheet> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Заряда хватит на 4 часа 17 минут\nили 47 км',
'Заряда хватит на $kmText или $timeText',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
@@ -288,10 +308,10 @@ class _TariffSheetState extends State<TariffSheet> {
child: PaymentOption(
title: state.useBalance
? 'Баланс'
: state.selectedCard!.type,
: "Карта",
subtitle: state.useBalance
? '${state.userBalance.toStringAsFixed(2)} BYN'
: '****${state.selectedCard!.cardLastNumber}',
: '${state.selectedCard!.cardLastNumber}',
isSelected: true,
onTap: () async {
final result = await showModalBottomSheet<dynamic>(
@@ -301,6 +321,9 @@ class _TariffSheetState extends State<TariffSheet> {
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
getIt<SetMainPaymentCardUsecase>(),
getIt<GetProfileUseCase>(),
getIt<AddPaymentCardUsecase>(),
)..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard,
@@ -381,7 +404,6 @@ class _TariffSheetState extends State<TariffSheet> {
const SizedBox(height: 12),
// 🔹 КНОПКА "ЗАБРОНИРОВАТЬ"
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GradientButton(
@@ -494,13 +516,10 @@ class _TariffCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// Резервируем место под иконку инфо справа,
// чтобы текст не залез под неё
const SizedBox(width: 24),
],
),
const SizedBox(height: 12),
// Цена + текст рядом
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
@@ -540,7 +559,6 @@ class _TariffCard extends StatelessWidget {
],
),
// Кнопка-иконка в верхнем правом углу
Positioned(
top: 0,
right: 0,
@@ -550,7 +568,7 @@ class _TariffCard extends StatelessWidget {
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => TariffInfoSheet(
builder: (_) => TariffInfoSheet(
tariff: tariff,
isInsurance: isInsurance,
onChanged: (val) {

View File

@@ -47,7 +47,7 @@ class SideMenu extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'Баланс ${state.balance} руб.',
'Баланс ${state.balance} баллов',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
@@ -63,68 +63,90 @@ class SideMenu extends StatelessWidget {
child: SingleChildScrollView(
child: Column(
children: [
// Пункты меню
Column(
children: [
_MenuItem(
icon: 'assets/icons/person_icon.png',
title: 'Профиль',
onTap: () => {
context.go('/home/profile'),
onTap: () {
Navigator.pop(context);
context.go('/home/profile');
},
),
_MenuItem(
icon: 'assets/icons/list_star_icon.png',
title: 'История поездок',
onTap: () => context.go("/home/order-history"),
onTap: () {
Navigator.pop(context);
context.go("/home/order-history");
},
),
const Divider(color: Colors.white30, height: 1),
_MenuItem(
icon: 'assets/icons/creditcard_icon.png',
title: 'Способ оплаты',
onTap: () => context.go("/home/payment-methods"),
onTap: () {
Navigator.pop(context);
context.go("/home/payment-methods");
},
),
_MenuItem(
icon: 'assets/icons/promo_icon.png',
title: 'Промокоды',
onTap: () => context.go("/home/promo"),
onTap: () {
Navigator.pop(context);
context.go("/home/promo");
},
),
_MenuItem(
icon: 'assets/icons/plans_icon.png',
title: 'Абонементы',
onTap: () => context.push("/home/subscriptions"),
onTap: () {
Navigator.pop(context);
context.push("/home/subscriptions");
},
),
const Divider(color: Colors.white30, height: 1),
_MenuItem(
icon: 'assets/icons/magazine_icon.png',
title: 'Правила пользования самокатом',
onTap: () => context.go("/home/rules")
onTap: () {
Navigator.pop(context);
context.go("/home/rules");
}
),
_MenuItem(
icon: 'assets/icons/headphones_icon.png',
title: 'Техподдержка',
onTap: () => {
context.go('/home/support'),
onTap: () {
Navigator.pop(context);
context.go('/home/support');
},
),
_MenuItem(
icon: 'assets/icons/doc_icon.png',
title: 'Документы',
onTap: () => context.go("/home/documents"),
onTap: () {
Navigator.pop(context);
context.go("/home/documents");
},
),
_MenuItem(
icon: 'assets/icons/news_icon.png',
title: 'Новости',
onTap: () => context.go("/home/news"),
onTap: () {
Navigator.pop(context);
context.go("/home/news");
},
),
const Divider(color: Colors.white30, height: 1),
_MenuItem(
icon: 'assets/icons/logout_icon.png',
title: 'Выход',
onTap: () => {
context.go('/login'),
context.read<MapBloc>().stopNotificationStream(),
context.read<MapBloc>().add(LogoutPressed()),
onTap: () {
context.read<MapBloc>().stopNotificationStream();
context.read<MapBloc>().add(LogoutPressed());
context.go('/login');
},
),
],

View File

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

View File

@@ -30,4 +30,14 @@ class NotificationReceived extends ScooterEvent {
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,8 @@
sealed class NotificationsEvent {}
class NotificationsFetchRequested extends NotificationsEvent {
final int page;
NotificationsFetchRequested({this.page = 1});
}
class NotificationsLoadMoreRequested extends NotificationsEvent {}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
sealed class PromoCodeEvent {}
class PromoCodeApplyRequested extends PromoCodeEvent {
final String code;
PromoCodeApplyRequested(this.code);
}
class PromoCodeReset extends PromoCodeEvent {}

View File

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

View File

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

View File

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

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