fix to prod

This commit is contained in:
2026-08-14 13:47:11 +03:00
parent f53fea576e
commit 80a95ece5b
27 changed files with 872 additions and 221 deletions

View File

@@ -26,7 +26,7 @@
<meta-data <meta-data
android:name="com.yandex.runtime.API_KEY" android:name="com.yandex.runtime.API_KEY"
android:value="a0ef1404-2650-4f28-9891-c965ecc09174" /> android:value="45fe500d-b549-4444-9e4b-3b2acd78ee2c" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -6,7 +6,7 @@ import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override protected fun onCreate(savedInstanceState: Bundle?) { override protected fun onCreate(savedInstanceState: Bundle?) {
MapKitFactory.setApiKey("a0ef1404-2650-4f28-9891-c965ecc09174") MapKitFactory.setApiKey("45fe500d-b549-4444-9e4b-3b2acd78ee2c")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
} }
} }

BIN
assets/icons/fine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,6 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
#include? "Secrets.xcconfig" #include? "Secrets.xcconfig"
YANDEX_MAPKIT_API_KEY=a0ef1404-2650-4f28-9891-c965ecc09174 YANDEX_MAPKIT_API_KEY=45fe500d-b549-4444-9e4b-3b2acd78ee2c
// Переопределение Pods/ML Kit: arm64 для симулятора на Apple Silicon (M1/M4) // Переопределение Pods/ML Kit: arm64 для симулятора на Apple Silicon (M1/M4)
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386

View File

@@ -1,5 +1,5 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
#include? "Secrets.xcconfig" #include? "Secrets.xcconfig"
YANDEX_MAPKIT_API_KEY=a0ef1404-2650-4f28-9891-c965ecc09174 YANDEX_MAPKIT_API_KEY=45fe500d-b549-4444-9e4b-3b2acd78ee2c
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386

View File

@@ -1,5 +1,5 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
#include? "Secrets.xcconfig" #include? "Secrets.xcconfig"
YANDEX_MAPKIT_API_KEY=a0ef1404-2650-4f28-9891-c965ecc09174 YANDEX_MAPKIT_API_KEY=45fe500d-b549-4444-9e4b-3b2acd78ee2c
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386

View File

@@ -36,8 +36,8 @@ import '../models/subscriptions_response.dart';
import '../models/user_check_response_dto.dart'; import '../models/user_check_response_dto.dart';
class ApiService { class ApiService {
static const String baseUrl = "https://sharing-api.sparkit.by/api/v1"; static const String baseUrl = "https://api.behappybel.by/api/v1";
static const String fileBaseUrl = "https://sharing-api.sparkit.by"; static const String fileBaseUrl = "https://api.behappybel.by/";
final SecurityService _securityService; final SecurityService _securityService;
final Dio _dio; final Dio _dio;
@@ -935,11 +935,30 @@ class ApiService {
} }
} }
/// GET /scooterorder/fines/client
Future<List<Fine>> getClientFines() async {
try {
final response = await _dio.get(
"$baseUrl/scooterorder/fines/client",
options: await _getAuthOptions(),
);
if (response.statusCode == 200 && response.data is Map) {
final List<dynamic> dataList = response.data['data'] ?? [];
return dataList.map((json) => Fine.fromJson(json)).toList();
}
return [];
} on DioException catch (e) {
_handleDioError(e);
return [];
}
}
/// GET /scooterorder/{id}/fine /// GET /scooterorder/{id}/fine
Future<Fine?> getFineDetails(int orderId) async { Future<Fine?> getFineDetails(int orderId) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
"$baseUrl/scooterorder/$orderId/fine", "$baseUrl/scooterorder/fines/client/$orderId",
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );
@@ -953,7 +972,8 @@ class ApiService {
} }
} }
/// PUT /scooterorder/{id}/fine /// PUT /scooterorder/fines/client/{id}
/// {id} — это id штрафа (Fine.id), а не id заказа (Fine.orderId)
Future<void> payFine({ Future<void> payFine({
required int orderId, required int orderId,
int? cardId, int? cardId,
@@ -961,10 +981,10 @@ class ApiService {
}) async { }) async {
try { try {
await _dio.put( await _dio.put(
"$baseUrl/scooterorder/$orderId/fine", "$baseUrl/scooterorder/fines/client/$orderId",
data: { data: {
if (cardId != null) 'cardId': cardId, "cardId": cardId ?? 0,
'isBalance': isBalance, "isBalance": isBalance,
}, },
options: await _getAuthOptions(), options: await _getAuthOptions(),
); );

View File

@@ -449,6 +449,24 @@ class ScooterRepositoryImpl extends ScooterRepository {
return result; return result;
} }
@override
Future<Result<List<Fine>>> getClientFines() async {
late final Result<List<Fine>> result;
try {
final fines = await _apiService.getClientFines();
result = Success(fines);
} on AuthException catch (e) {
result = Failure(AuthFailure(e.attemptsLeft));
} on UnauthorizedException catch (e) {
result = Failure(AuthFailure(0, message: e.toString()));
} on AuthBlockException catch (e) {
result = Failure(AuthBlockFailure(e.toString()));
} catch (e) {
result = Failure(UnknownFailure("Неизвестная ошибка"));
}
return result;
}
@override @override
Future<Result<void>> payFine({ Future<Result<void>> payFine({
required int orderId, required int orderId,

View File

@@ -94,14 +94,18 @@ import '../domain/service/device_info_service.dart';
import '../domain/usecase/activate_subscription_usecase.dart'; import '../domain/usecase/activate_subscription_usecase.dart';
import '../domain/usecase/apply_promo_code_usecase.dart'; import '../domain/usecase/apply_promo_code_usecase.dart';
import '../domain/usecase/get_client_subscriptions_usecase.dart'; import '../domain/usecase/get_client_subscriptions_usecase.dart';
import '../domain/usecase/get_fine_details_usecase.dart';
import '../domain/usecase/get_fines_usecase.dart';
import '../domain/usecase/get_news_by_id_usecase.dart'; import '../domain/usecase/get_news_by_id_usecase.dart';
import '../domain/usecase/get_notifications_usecase.dart'; import '../domain/usecase/get_notifications_usecase.dart';
import '../domain/usecase/get_scooter_by_title_usecase.dart'; import '../domain/usecase/get_scooter_by_title_usecase.dart';
import '../domain/usecase/get_scooter_order_history_usecase.dart'; import '../domain/usecase/get_scooter_order_history_usecase.dart';
import '../domain/usecase/pay_fine_usecase.dart';
import '../domain/usecase/remove_payment_card_usecase.dart'; import '../domain/usecase/remove_payment_card_usecase.dart';
import '../domain/usecase/set_main_payment_card_usecase.dart'; import '../domain/usecase/set_main_payment_card_usecase.dart';
import '../presentation/viewmodel/auth_bloc.dart'; import '../presentation/viewmodel/auth_bloc.dart';
import '../presentation/viewmodel/edit_profile_bloc.dart'; import '../presentation/viewmodel/edit_profile_bloc.dart';
import '../presentation/viewmodel/fine_list_bloc.dart';
import '../presentation/viewmodel/map_bloc.dart'; import '../presentation/viewmodel/map_bloc.dart';
import '../presentation/viewmodel/news_bloc.dart'; import '../presentation/viewmodel/news_bloc.dart';
import '../presentation/viewmodel/notifications_bloc.dart'; import '../presentation/viewmodel/notifications_bloc.dart';
@@ -391,6 +395,8 @@ Future<void> setupDependencies() async {
() => OrderHistoryBloc(getIt<GetScooterOrderHistoryUsecase>()), () => OrderHistoryBloc(getIt<GetScooterOrderHistoryUsecase>()),
); );
getIt.registerFactory<FineListBloc>(() => FineListBloc(getIt()));
getIt.registerFactory<ScooterCodeBloc>( getIt.registerFactory<ScooterCodeBloc>(
() => ScooterCodeBloc( () => ScooterCodeBloc(
getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>(), getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>(),
@@ -409,6 +415,12 @@ Future<void> setupDependencies() async {
ApplyPromoCodeUsecase(getIt<PromoCodeRepository>()), ApplyPromoCodeUsecase(getIt<PromoCodeRepository>()),
); );
getIt.registerFactory<GetFinesUsecase>(() => GetFinesUsecase(getIt()));
getIt.registerFactory<GetFineDetailsUsecase>(() => GetFineDetailsUsecase(getIt()));
getIt.registerFactory<PayFineUsecase>(() => PayFineUsecase(getIt()));
// Bloc (factory, т.к. экран создаёт новый экземпляр) // Bloc (factory, т.к. экран создаёт новый экземпляр)
getIt.registerFactory<PromoCodeBloc>( getIt.registerFactory<PromoCodeBloc>(
() => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()), () => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),

View File

@@ -2,8 +2,9 @@ import 'package:equatable/equatable.dart';
class Fine extends Equatable { class Fine extends Equatable {
final int id; final int id;
final String scooterNumber; final int orderId; // ← добавлено
final String date; final String scooterNumber; // можно оставить для совместимости, но не использовать в UI
final DateTime createdAtDateTime; // ← новое поле
final String description; final String description;
final double amount; final double amount;
final String amountPrint; final String amountPrint;
@@ -12,8 +13,9 @@ class Fine extends Equatable {
const Fine({ const Fine({
required this.id, required this.id,
required this.orderId,
required this.scooterNumber, required this.scooterNumber,
required this.date, required this.createdAtDateTime,
required this.description, required this.description,
required this.amount, required this.amount,
required this.amountPrint, required this.amountPrint,
@@ -28,22 +30,15 @@ class Fine extends Equatable {
.where((path) => path.isNotEmpty) .where((path) => path.isNotEmpty)
.toList(); .toList();
// Форматирование даты из createdAt (2026-08-07T09:15:26.565Z -> 07-08-2026) // Парсим createdAt как DateTime
String formattedDate = ''; final createdAtDateTime = DateTime.tryParse(json['createdAt'].toString()) ??
if (json['createdAt'] != null) { DateTime.now(); // fallback
final parsedDate = DateTime.tryParse(json['createdAt'].toString());
if (parsedDate != null) {
final day = parsedDate.day.toString().padLeft(2, '0');
final month = parsedDate.month.toString().padLeft(2, '0');
final year = parsedDate.year;
formattedDate = '$day-$month-$year';
}
}
return Fine( return Fine(
id: json['id'] as int? ?? 0, id: json['id'] as int? ?? 0,
orderId: json['orderId'] as int? ?? 0,
scooterNumber: scooterNumber ?? '', scooterNumber: scooterNumber ?? '',
date: formattedDate, createdAtDateTime: createdAtDateTime,
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
amount: (json['value'] as num?)?.toDouble() ?? 0.0, amount: (json['value'] as num?)?.toDouble() ?? 0.0,
amountPrint: json['valuePrint'] as String? ?? '0,00 BYN', amountPrint: json['valuePrint'] as String? ?? '0,00 BYN',
@@ -52,21 +47,12 @@ class Fine extends Equatable {
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'description': description,
'value': amount,
'valuePrint': amountPrint,
'isPaid': isPaid,
};
}
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
orderId,
scooterNumber, scooterNumber,
date, createdAtDateTime,
description, description,
amount, amount,
amountPrint, amountPrint,

View File

@@ -62,4 +62,6 @@ abstract class ScooterRepository {
required bool isBalance, required bool isBalance,
}); });
Future<Result<List<Fine>>> getClientFines();
} }

View File

@@ -0,0 +1,13 @@
import '../../core/result.dart';
import '../entities/fine.dart';
import '../repositories/scooter_repository.dart';
class GetFinesUsecase {
final ScooterRepository repository;
GetFinesUsecase(this.repository);
Future<Result<List<Fine>>> call() {
return repository.getClientFines();
}
}

View File

@@ -23,6 +23,11 @@ class _FullScreenImageViewerState extends State<FullScreenImageViewer> {
_pageController = PageController(initialPage: widget.initialIndex); _pageController = PageController(initialPage: widget.initialIndex);
} }
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return 'https://sharing-api.sparkit.by/$path';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -35,12 +40,17 @@ class _FullScreenImageViewerState extends State<FullScreenImageViewer> {
controller: _pageController, controller: _pageController,
itemCount: widget.photoUrls.length, itemCount: widget.photoUrls.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final isNetwork = widget.photoUrls[index].startsWith('http'); final photo = widget.photoUrls[index];
final imageUrl = _resolveImageUrl(photo);
return InteractiveViewer( return InteractiveViewer(
child: Center( child: Center(
child: isNetwork child: Image.network(
? Image.network(widget.photoUrls[index]) imageUrl,
: Image.asset(widget.photoUrls[index]), fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.error, color: Colors.white, size: 64);
},
),
), ),
); );
}, },

View File

@@ -73,6 +73,14 @@ class SideMenu extends StatelessWidget {
context.go('/home/profile'); context.go('/home/profile');
}, },
), ),
_MenuItem(
icon: 'assets/icons/fine.png',
title: 'Штрафы',
onTap: () {
Navigator.pop(context);
context.go('/home/fine-list');
},
),
_MenuItem( _MenuItem(
icon: 'assets/icons/list_star_icon.png', icon: 'assets/icons/list_star_icon.png',
title: 'История поездок', title: 'История поездок',

View File

@@ -0,0 +1,5 @@
abstract class FineListEvent {}
class FetchFinesRequested extends FineListEvent {}
class RefreshFinesRequested extends FineListEvent {}

View File

@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:be_happy/presentation/screens/card_binding_result_screen.dart'; import 'package:be_happy/presentation/screens/card_binding_result_screen.dart';
import 'package:be_happy/presentation/screens/company_details.dart';
import 'package:be_happy/presentation/screens/lease_agreement_screen.dart'; import 'package:be_happy/presentation/screens/lease_agreement_screen.dart';
import 'package:be_happy/presentation/screens/payment_rules_screen.dart'; import 'package:be_happy/presentation/screens/payment_rules_screen.dart';
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.dart';
@@ -79,6 +78,9 @@ import '../event/subscription_details_event.dart';
import '../event/tariff_sheet_event.dart'; import '../event/tariff_sheet_event.dart';
import '../event/top_up_event.dart'; import '../event/top_up_event.dart';
import '../screens/add_card_screen.dart'; // ← новый импорт import '../screens/add_card_screen.dart'; // ← новый импорт
import '../screens/company_details_screen.dart';
import '../screens/fine_list_screen.dart';
import '../screens/fine_payment_screen.dart';
import '../screens/license_agreement_screen.dart'; import '../screens/license_agreement_screen.dart';
import '../screens/notifications_screen.dart'; import '../screens/notifications_screen.dart';
import '../screens/order_history_screen.dart'; import '../screens/order_history_screen.dart';
@@ -545,6 +547,41 @@ class AppRouter {
builder: (context, state) => const OrderHistoryScreen(), builder: (context, state) => const OrderHistoryScreen(),
routes: [] routes: []
), ),
GoRoute(
path: 'fine-list',
builder: (context, state) => const FineListScreen(),
routes: []
),
GoRoute(
path: 'fine-payment/:fineId',
builder: (context, state) {
final fineId = int.parse(state.pathParameters['fineId']!);
return FinePaymentScreen(fineId: fineId);
},
routes: [
GoRoute(
path: 'result/:status',
builder: (context, state) {
final modeParam = state.pathParameters['status'];
CardBindingStatus status = CardBindingStatus.success;
String title = 'Штраф успешно\nоплачен!';
if (modeParam == 'failed') {
status = CardBindingStatus.failed;
title = 'Извините, что-то\nпошло не так';
}
return CardBindingResultScreen(
status: status,
title: title,
cardImagePath: 'assets/icons/card_result_screen.png',
redirectPath: '/home/fine-list',
);
},
),
],
),
GoRoute( GoRoute(
path: 'notifications', path: 'notifications',
builder: (context, state) => const NotificationsScreen(), builder: (context, state) => const NotificationsScreen(),

View File

@@ -10,12 +10,14 @@ class CardBindingResultScreen extends StatefulWidget {
final CardBindingStatus status; final CardBindingStatus status;
final String title; final String title;
final String cardImagePath; final String cardImagePath;
final String redirectPath; // ← новое поле
const CardBindingResultScreen({ const CardBindingResultScreen({
super.key, super.key,
required this.status, required this.status,
required this.title, required this.title,
required this.cardImagePath, required this.cardImagePath,
this.redirectPath = '/home/payment-methods', // сохраняем текущее поведение по умолчанию
}); });
@override @override
@@ -32,7 +34,7 @@ class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
_autoPopTimer = Timer(const Duration(seconds: 5), () { _autoPopTimer = Timer(const Duration(seconds: 5), () {
if (mounted) { if (mounted) {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp"); context.go("${widget.redirectPath}?refreshId=$timestamp");
} }
}); });
} }
@@ -128,7 +130,7 @@ class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
text: "Попробовать снова", text: "Попробовать снова",
onTap: () { onTap: () {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp"); context.go("${widget.redirectPath}?refreshId=$timestamp");
}, },
enabled: true, enabled: true,
showArrows: true, showArrows: true,
@@ -142,7 +144,7 @@ class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
onPressed: () onPressed: ()
{ {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
context.go("/home/payment-methods?refreshId=$timestamp"); context.go("${widget.redirectPath}?refreshId=$timestamp");
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View File

@@ -0,0 +1,376 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/fine.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../event/fine_list_event.dart';
import '../state/fine_list_state.dart';
import '../viewmodel/fine_list_bloc.dart';
import 'fine_payment_screen.dart';
class FineListScreen extends StatelessWidget {
const FineListScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<FineListBloc>()..add(FetchFinesRequested()),
child: const FineListView(),
);
}
}
class FineListView extends StatelessWidget {
const FineListView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Штрафы'),
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<FineListBloc, FineListState>(
builder: (context, state) {
if (state.status == FineListStatus.loading && state.fines.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (state.status == FineListStatus.failure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Ошибка загрузки',
style: TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
Text(
state.errorMessage ?? '',
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<FineListBloc>().add(FetchFinesRequested());
},
child: const Text('Повторить'),
),
],
),
);
}
if (state.status == FineListStatus.empty) {
return const _EmptyState();
}
final groupedFines = _groupByMonth(state.fines);
return RefreshIndicator(
onRefresh: () async {
context.read<FineListBloc>().add(RefreshFinesRequested());
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: groupedFines.length,
itemBuilder: (context, index) {
final monthKey = groupedFines.keys.elementAt(index);
final fines = groupedFines[monthKey]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
_getMonthTitle(monthKey),
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
...fines.map((fine) => _FineCard(fine: fine)),
],
);
},
),
);
},
),
),
],
),
),
),
);
}
Map<String, List<Fine>> _groupByMonth(List<Fine> fines) {
final Map<String, List<Fine>> grouped = {};
for (var fine in fines) {
final localDate = fine.createdAtDateTime.toLocal();
final monthKey = '${localDate.year}-${localDate.month.toString().padLeft(2, '0')}';
grouped.putIfAbsent(monthKey, () => []);
grouped[monthKey]!.add(fine);
}
final sortedKeys = grouped.keys.toList()..sort((a, b) => b.compareTo(a));
return Map.fromEntries(
sortedKeys.map((key) => MapEntry(key, grouped[key]!)),
);
}
String _getMonthTitle(String monthKey) {
final parts = monthKey.split('-');
final year = int.parse(parts[0]);
final month = int.parse(parts[1]);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
return '${months[month - 1]} $year';
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/history.png',
width: 380,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 280,
height: 280,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.warning_amber_outlined,
size: 120,
color: Colors.white38,
),
);
},
),
const SizedBox(height: 40),
const Text(
'У вас пока нет штрафов.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.4,
),
),
const SizedBox(height: 32),
GradientButton(
text: 'Прокатиться',
showArrows: true,
height: 56,
width: double.infinity,
fontSize: 16,
onTap: () {
context.go('/home');
},
),
],
),
),
);
}
}
// 🔹 КАРТОЧКА ШТРАФА — полностью как _OrderCard, но с Fine
class _FineCard extends StatelessWidget {
final Fine fine;
const _FineCard({required this.fine});
@override
Widget build(BuildContext context) {
final date = _formatDateFromFineDate(fine.createdAtDateTime); // преобразуем "16-06-2025" → "16 июня, 15:48"
final orderId = '${fine.orderId}';
final price = fine.amountPrint;
return GestureDetector(
onTap: () {
context.push('/home/fine-payment/${fine.id}'); },
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.fromLTRB(4, 4, 16, 4),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E).withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.warning_amber,
color: Colors.white,
size: 48,
);
},
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.qr_code_2,
color: Colors.white.withOpacity(0.6),
size: 18,
),
const SizedBox(width: 4),
Text(
orderId,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
// Столбец 3: Статус + цена
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 100,
alignment: Alignment.center,
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
fine.isPaid ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
style: TextStyle(
color: fine.isPaid
? Colors.greenAccent
: Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 8),
Text(
price,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
);
}
String _formatDateFromFineDate(DateTime dateTime) {
final now = DateTime.now();
final localDate = dateTime.toLocal();
final isToday = localDate.year == now.year &&
localDate.month == now.month &&
localDate.day == now.day;
final yesterday = now.subtract(const Duration(days: 1));
final isYesterday = localDate.year == yesterday.year &&
localDate.month == yesterday.month &&
localDate.day == yesterday.day;
final time = '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
if (isToday) {
return 'Сегодня, $time';
} else if (isYesterday) {
return 'Вчера, $time';
} else {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return '${localDate.day} ${months[localDate.month - 1]}, $time';
}
}
String _formatDateFull(DateTime date) {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return '${date.day} ${months[date.month - 1]}';
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../data/network/api_service.dart';
import '../../di/service_locator.dart'; import '../../di/service_locator.dart';
import '../../domain/entities/payment_card.dart'; import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/add_payment_card_usecase.dart'; import '../../domain/usecase/add_payment_card_usecase.dart';
@@ -34,13 +35,20 @@ class FinePaymentScreen extends StatelessWidget {
getFineDetailsUsecase: getIt(), getFineDetailsUsecase: getIt(),
payFineUsecase: getIt(), payFineUsecase: getIt(),
)..add(FetchFineDetails(fineId)), )..add(FetchFineDetails(fineId)),
child: const _FinePaymentScreenContent(), child: _FinePaymentScreenContent(fineId: fineId),
); );
} }
} }
class _FinePaymentScreenContent extends StatelessWidget { class _FinePaymentScreenContent extends StatelessWidget {
const _FinePaymentScreenContent(); final int fineId;
const _FinePaymentScreenContent({required this.fineId});
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return 'https://sharing-api.sparkit.by/$path';
}
void _openFullScreenImage(BuildContext context, List<String> photos, int initialIndex) { void _openFullScreenImage(BuildContext context, List<String> photos, int initialIndex) {
Navigator.of(context).push( Navigator.of(context).push(
@@ -88,8 +96,13 @@ class _FinePaymentScreenContent extends StatelessWidget {
listenWhen: (previous, current) => previous.status != current.status, listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) { listener: (context, state) {
if (state.status == FinePaymentStatus.success && state.paymentCompleted) { if (state.status == FinePaymentStatus.success && state.paymentCompleted) {
context.go('/home'); context.go('/home/fine-payment/$fineId/result/success');
} else if (state.status == FinePaymentStatus.failure) { } else if (state.status == FinePaymentStatus.failure) {
if (state.fine != null) {
// штраф уже был загружен — значит ошибка произошла при попытке оплаты
context.go('/home/fine-payment/$fineId/result/failed');
} else {
// штраф не удалось загрузить — остаёмся на месте, показываем снекбар
ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -107,6 +120,7 @@ class _FinePaymentScreenContent extends StatelessWidget {
), ),
); );
} }
}
}, },
builder: (context, state) { builder: (context, state) {
final fine = state.fine; final fine = state.fine;
@@ -122,6 +136,8 @@ class _FinePaymentScreenContent extends StatelessWidget {
if (fine == null) return const SizedBox.shrink(); if (fine == null) return const SizedBox.shrink();
final bool isPaid = fine.isPaid;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -136,7 +152,7 @@ class _FinePaymentScreenContent extends StatelessWidget {
separatorBuilder: (_, __) => const SizedBox(width: 12), separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final photo = fine.photoUrls[index]; final photo = fine.photoUrls[index];
final isNetwork = photo.startsWith('http'); final imageUrl = _resolveImageUrl(photo);
return GestureDetector( return GestureDetector(
onTap: () => _openFullScreenImage( onTap: () => _openFullScreenImage(
context, context,
@@ -145,18 +161,35 @@ class _FinePaymentScreenContent extends StatelessWidget {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: isNetwork child: Image.network(
? Image.network( imageUrl,
photo,
width: 110, width: 110,
height: 110, height: 110,
fit: BoxFit.cover, fit: BoxFit.cover,
) loadingBuilder: (context, child, loadingProgress) {
: Image.asset( if (loadingProgress == null) return child;
photo, return SizedBox(
width: 110, width: 110,
height: 110, height: 110,
fit: BoxFit.cover, child: Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.error, color: Colors.white, size: 48);
},
), ),
), ),
); );
@@ -166,17 +199,17 @@ class _FinePaymentScreenContent extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildInfoLabel('Самокат:'), _buildInfoLabel('Номер заказа:'),
_buildValueRow( _buildValueRow(
'qr_icon.png', 'qr_icon.png',
fine.scooterNumber, '${fine.orderId}',
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildInfoLabel('Дата:'), _buildInfoLabel('Дата и время:'),
Text( Text(
fine.date, _formatDateTime(fine.createdAtDateTime),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,
@@ -244,6 +277,7 @@ class _FinePaymentScreenContent extends StatelessWidget {
const SizedBox(height: 28), const SizedBox(height: 28),
if (!isPaid)
(state.useBalance || state.selectedCard != null) (state.useBalance || state.selectedCard != null)
? PaymentOption( ? PaymentOption(
title: state.useBalance ? 'Баланс' : "", title: state.useBalance ? 'Баланс' : "",
@@ -361,6 +395,11 @@ class _FinePaymentScreenContent extends StatelessWidget {
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: BlocBuilder<FinePaymentBloc, FinePaymentState>( child: BlocBuilder<FinePaymentBloc, FinePaymentState>(
builder: (context, state) { builder: (context, state) {
final fine = state.fine;
if (fine == null || fine.isPaid) {
return const SizedBox.shrink();
}
final isButtonDisabled = state.status == FinePaymentStatus.loading || final isButtonDisabled = state.status == FinePaymentStatus.loading ||
(!state.useBalance && state.selectedCard == null); (!state.useBalance && state.selectedCard == null);
@@ -418,4 +457,32 @@ class _FinePaymentScreenContent extends StatelessWidget {
], ],
); );
} }
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final localDate = dateTime.toLocal();
final isToday = localDate.year == now.year &&
localDate.month == now.month &&
localDate.day == now.day;
final yesterday = now.subtract(const Duration(days: 1));
final isYesterday = localDate.year == yesterday.year &&
localDate.month == yesterday.month &&
localDate.day == yesterday.day;
final time = '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
if (isToday) {
return 'Сегодня, $time';
} else if (isYesterday) {
return 'Вчера, $time';
} else {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return '${localDate.day} ${months[localDate.month - 1]}, $time';
}
}
} }

View File

@@ -193,7 +193,7 @@ class _EmptyState extends StatelessWidget {
const SizedBox(height: 40), const SizedBox(height: 40),
const Text( const Text(
'У вас пока нет завершенных поездок.', 'У вас пока нет завершенных поездок',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
@@ -232,7 +232,7 @@ class _OrderCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now()); final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
final scooterNumber = order.scooter?.title ?? '№${order.scooterId}'; final scooterNumber = order.scooter?.title ?? '№${order.scooterId}';
final price = '${order.totalPrice?.toStringAsFixed(2)} BYN' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN'; final price = '${order.totalPrice?.toStringAsFixed(2)} руб.' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {

View File

@@ -22,41 +22,6 @@ class PhoneScreen extends StatefulWidget {
class _PhoneScreenState extends State<PhoneScreen> { class _PhoneScreenState extends State<PhoneScreen> {
final TextEditingController _phoneController = TextEditingController(); final TextEditingController _phoneController = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_showTestModeDialog();
});
}
void _showTestModeDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'Внимание!',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: const Text('Приложение находится в тестовом режиме!'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
'Продолжить',
style: TextStyle(color: Colors.blueAccent),
),
),
],
);
},
);
}
@override @override
void dispose() { void dispose() {
_phoneController.dispose(); _phoneController.dispose();

View File

@@ -26,21 +26,21 @@ class SupportScreen extends StatelessWidget {
LinkRow( LinkRow(
icon: 'assets/icons/telegram.png', icon: 'assets/icons/telegram.png',
title: 'Telegram', title: 'Telegram',
onTap: () => openLink('https://t.me/...'), onTap: () => openLink('https://t.me/BeHappySupportt'),
), ),
const Divider(height: 1, color: Colors.white24), const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),
LinkRow( LinkRow(
icon: 'assets/icons/whatsapp.png', icon: 'assets/icons/whatsapp.png',
title: 'WhatsApp', title: 'WhatsApp',
onTap: () => openLink('https://wa.me/...'), onTap: () => openLink('https://wa.me/375257110909'),
), ),
const Divider(height: 1, color: Colors.white24), const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),
LinkRow( LinkRow(
icon: 'assets/icons/viber.png', icon: 'assets/icons/viber.png',
title: 'Viber', title: 'Viber',
onTap: () => openLink('viber://chat?number=...'), onTap: () => openLink('viber://chat?number=+375257110909'),
), ),
const Divider(height: 1, color: Colors.white24), const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/fine.dart';
enum FineListStatus { loading, success, failure, empty }
class FineListState extends Equatable {
final FineListStatus status;
final List<Fine> fines;
final String? errorMessage;
const FineListState({
this.status = FineListStatus.loading,
this.fines = const [],
this.errorMessage,
});
FineListState copyWith({
FineListStatus? status,
List<Fine>? fines,
String? errorMessage,
}) {
return FineListState(
status: status ?? this.status,
fines: fines ?? this.fines,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, fines, errorMessage];
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/result.dart'; // Убедитесь, что импортирован!
import '../../domain/entities/fine.dart';
import '../../domain/usecase/get_fines_usecase.dart';
import '../event/fine_list_event.dart';
import '../state/fine_list_state.dart';
class FineListBloc extends Bloc<FineListEvent, FineListState> {
final GetFinesUsecase getFinesUsecase;
FineListBloc(this.getFinesUsecase) : super(const FineListState()) {
on<FetchFinesRequested>(_onFetchFines);
on<RefreshFinesRequested>(_onRefreshFines);
}
Future<void> _onFetchFines(
FetchFinesRequested event,
Emitter<FineListState> emit,
) async {
emit(state.copyWith(status: FineListStatus.loading));
try {
final result = await getFinesUsecase(); // Возвращает Result<List<Fine>>
// Распаковываем Result
if (result is Success<List<Fine>>) {
final fines = result.data ?? []; // List<Fine>
emit(state.copyWith(
status: fines.isEmpty ? FineListStatus.empty : FineListStatus.success,
fines: fines,
));
} else if (result is Failure<List<Fine>>) {
emit(state.copyWith(
status: FineListStatus.failure,
errorMessage: result.failure.toString(), // или конкретное сообщение
));
}
} catch (e) {
emit(state.copyWith(
status: FineListStatus.failure,
errorMessage: e.toString(),
));
}
}
Future<void> _onRefreshFines(
RefreshFinesRequested event,
Emitter<FineListState> emit,
) async {
emit(state.copyWith(status: FineListStatus.loading));
try {
final result = await getFinesUsecase(); // Возвращает Result<List<Fine>>
// Распаковываем Result
if (result is Success<List<Fine>>) {
final fines = result.data ?? []; // List<Fine>
emit(state.copyWith(
status: fines.isEmpty ? FineListStatus.empty : FineListStatus.success,
fines: fines,
));
} else if (result is Failure<List<Fine>>) {
emit(state.copyWith(
status: FineListStatus.failure,
errorMessage: result.failure.toString(), // или конкретное сообщение
));
}
} catch (e) {
emit(state.copyWith(
status: FineListStatus.failure,
errorMessage: e.toString(),
));
}
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/result.dart';import '../../domain/entities/fine.dart';
import '../../domain/usecase/get_fine_details_usecase.dart'; import '../../domain/usecase/get_fine_details_usecase.dart';
import '../../domain/usecase/pay_fine_usecase.dart'; import '../../domain/usecase/pay_fine_usecase.dart';
import '../event/fine_payment_event.dart'; import '../event/fine_payment_event.dart';
@@ -24,8 +26,32 @@ class FinePaymentBloc extends Bloc<FinePaymentEvent, FinePaymentState> {
) async { ) async {
emit(state.copyWith(status: FinePaymentStatus.loading)); emit(state.copyWith(status: FinePaymentStatus.loading));
try { try {
final fine = await getFineDetailsUsecase(event.fineId); final result = await getFineDetailsUsecase(event.fineId); // Возвращает Result<Fine>
// emit(state.copyWith(status: FinePaymentStatus.initial, fine: fine));
// Распаковываем Result
if (result is Success<Fine>) {
final fine = result.data; // Fine?
if (fine != null) {
emit(state.copyWith(
status: FinePaymentStatus.initial,
fine: fine,
));
} else {
emit(
state.copyWith(
status: FinePaymentStatus.failure,
errorMessage: "Не удалось получить данные о штрафе",
),
);
}
} else if (result is Failure<Fine>) {
emit(
state.copyWith(
status: FinePaymentStatus.failure,
errorMessage: result.failure.toString(), // или конкретное сообщение
),
);
}
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(

View File

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