diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e6c0a2b..0a911b6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ + android:value="45fe500d-b549-4444-9e4b-3b2acd78ee2c" /> > 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 dataList = response.data['data'] ?? []; + return dataList.map((json) => Fine.fromJson(json)).toList(); + } + return []; + } on DioException catch (e) { + _handleDioError(e); + return []; + } + } + /// GET /scooterorder/{id}/fine Future getFineDetails(int orderId) async { try { final response = await _dio.get( - "$baseUrl/scooterorder/$orderId/fine", + "$baseUrl/scooterorder/fines/client/$orderId", 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 payFine({ required int orderId, int? cardId, @@ -961,10 +981,10 @@ class ApiService { }) async { try { await _dio.put( - "$baseUrl/scooterorder/$orderId/fine", + "$baseUrl/scooterorder/fines/client/$orderId", data: { - if (cardId != null) 'cardId': cardId, - 'isBalance': isBalance, + "cardId": cardId ?? 0, + "isBalance": isBalance, }, options: await _getAuthOptions(), ); diff --git a/lib/data/repositories/scooter_repository_impl.dart b/lib/data/repositories/scooter_repository_impl.dart index 0655491..8907467 100644 --- a/lib/data/repositories/scooter_repository_impl.dart +++ b/lib/data/repositories/scooter_repository_impl.dart @@ -449,6 +449,24 @@ class ScooterRepositoryImpl extends ScooterRepository { return result; } + @override + Future>> getClientFines() async { + late final Result> 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 Future> payFine({ required int orderId, diff --git a/lib/di/service_locator.dart b/lib/di/service_locator.dart index 85b15d4..3b61f41 100644 --- a/lib/di/service_locator.dart +++ b/lib/di/service_locator.dart @@ -94,14 +94,18 @@ 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_fine_details_usecase.dart'; +import '../domain/usecase/get_fines_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/pay_fine_usecase.dart'; import '../domain/usecase/remove_payment_card_usecase.dart'; import '../domain/usecase/set_main_payment_card_usecase.dart'; import '../presentation/viewmodel/auth_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/news_bloc.dart'; import '../presentation/viewmodel/notifications_bloc.dart'; @@ -391,6 +395,8 @@ Future setupDependencies() async { () => OrderHistoryBloc(getIt()), ); + getIt.registerFactory(() => FineListBloc(getIt())); + getIt.registerFactory( () => ScooterCodeBloc( getScooterByTitleUsecase: getIt(), @@ -409,6 +415,12 @@ Future setupDependencies() async { ApplyPromoCodeUsecase(getIt()), ); + getIt.registerFactory(() => GetFinesUsecase(getIt())); + + getIt.registerFactory(() => GetFineDetailsUsecase(getIt())); + + getIt.registerFactory(() => PayFineUsecase(getIt())); + // Bloc (factory, т.к. экран создаёт новый экземпляр) getIt.registerFactory( () => PromoCodeBloc(getIt()), diff --git a/lib/domain/entities/fine.dart b/lib/domain/entities/fine.dart index 929590e..19b44f1 100644 --- a/lib/domain/entities/fine.dart +++ b/lib/domain/entities/fine.dart @@ -2,8 +2,9 @@ import 'package:equatable/equatable.dart'; class Fine extends Equatable { final int id; - final String scooterNumber; - final String date; + final int orderId; // ← добавлено + final String scooterNumber; // можно оставить для совместимости, но не использовать в UI + final DateTime createdAtDateTime; // ← новое поле final String description; final double amount; final String amountPrint; @@ -12,8 +13,9 @@ class Fine extends Equatable { const Fine({ required this.id, + required this.orderId, required this.scooterNumber, - required this.date, + required this.createdAtDateTime, required this.description, required this.amount, required this.amountPrint, @@ -28,22 +30,15 @@ class Fine extends Equatable { .where((path) => path.isNotEmpty) .toList(); - // Форматирование даты из createdAt (2026-08-07T09:15:26.565Z -> 07-08-2026) - String formattedDate = ''; - if (json['createdAt'] != null) { - 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'; - } - } + // Парсим createdAt как DateTime + final createdAtDateTime = DateTime.tryParse(json['createdAt'].toString()) ?? + DateTime.now(); // fallback return Fine( id: json['id'] as int? ?? 0, + orderId: json['orderId'] as int? ?? 0, scooterNumber: scooterNumber ?? '', - date: formattedDate, + createdAtDateTime: createdAtDateTime, description: json['description'] as String? ?? '', amount: (json['value'] as num?)?.toDouble() ?? 0.0, amountPrint: json['valuePrint'] as String? ?? '0,00 BYN', @@ -52,21 +47,12 @@ class Fine extends Equatable { ); } - Map toJson() { - return { - 'id': id, - 'description': description, - 'value': amount, - 'valuePrint': amountPrint, - 'isPaid': isPaid, - }; - } - @override List get props => [ id, + orderId, scooterNumber, - date, + createdAtDateTime, description, amount, amountPrint, diff --git a/lib/domain/repositories/scooter_repository.dart b/lib/domain/repositories/scooter_repository.dart index b2b123a..f1302fc 100644 --- a/lib/domain/repositories/scooter_repository.dart +++ b/lib/domain/repositories/scooter_repository.dart @@ -62,4 +62,6 @@ abstract class ScooterRepository { required bool isBalance, }); + Future>> getClientFines(); + } diff --git a/lib/domain/usecase/get_fines_usecase.dart b/lib/domain/usecase/get_fines_usecase.dart new file mode 100644 index 0000000..0030c91 --- /dev/null +++ b/lib/domain/usecase/get_fines_usecase.dart @@ -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>> call() { + return repository.getClientFines(); + } +} \ No newline at end of file diff --git a/lib/presentation/components/full_screen_image_viewer.dart b/lib/presentation/components/full_screen_image_viewer.dart index 5b00b7d..a6a45ea 100644 --- a/lib/presentation/components/full_screen_image_viewer.dart +++ b/lib/presentation/components/full_screen_image_viewer.dart @@ -23,6 +23,11 @@ class _FullScreenImageViewerState extends State { _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 Widget build(BuildContext context) { return Scaffold( @@ -35,12 +40,17 @@ class _FullScreenImageViewerState extends State { controller: _pageController, itemCount: widget.photoUrls.length, itemBuilder: (context, index) { - final isNetwork = widget.photoUrls[index].startsWith('http'); + final photo = widget.photoUrls[index]; + final imageUrl = _resolveImageUrl(photo); return InteractiveViewer( child: Center( - child: isNetwork - ? Image.network(widget.photoUrls[index]) - : Image.asset(widget.photoUrls[index]), + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.error, color: Colors.white, size: 64); + }, + ), ), ); }, diff --git a/lib/presentation/components/side_menu.dart b/lib/presentation/components/side_menu.dart index b25a24f..9ecf5aa 100644 --- a/lib/presentation/components/side_menu.dart +++ b/lib/presentation/components/side_menu.dart @@ -73,6 +73,14 @@ class SideMenu extends StatelessWidget { context.go('/home/profile'); }, ), + _MenuItem( + icon: 'assets/icons/fine.png', + title: 'Штрафы', + onTap: () { + Navigator.pop(context); + context.go('/home/fine-list'); + }, + ), _MenuItem( icon: 'assets/icons/list_star_icon.png', title: 'История поездок', diff --git a/lib/presentation/event/fine_list_event.dart b/lib/presentation/event/fine_list_event.dart new file mode 100644 index 0000000..17a3def --- /dev/null +++ b/lib/presentation/event/fine_list_event.dart @@ -0,0 +1,5 @@ +abstract class FineListEvent {} + +class FetchFinesRequested extends FineListEvent {} + +class RefreshFinesRequested extends FineListEvent {} \ No newline at end of file diff --git a/lib/presentation/navigation/app_router.dart b/lib/presentation/navigation/app_router.dart index f54c9b9..e85c0cd 100644 --- a/lib/presentation/navigation/app_router.dart +++ b/lib/presentation/navigation/app_router.dart @@ -1,7 +1,6 @@ import 'dart:async'; 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/payment_rules_screen.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/top_up_event.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/notifications_screen.dart'; import '../screens/order_history_screen.dart'; @@ -545,6 +547,41 @@ class AppRouter { builder: (context, state) => const OrderHistoryScreen(), 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( path: 'notifications', builder: (context, state) => const NotificationsScreen(), diff --git a/lib/presentation/screens/card_binding_result_screen.dart b/lib/presentation/screens/card_binding_result_screen.dart index 5790e71..561f16f 100644 --- a/lib/presentation/screens/card_binding_result_screen.dart +++ b/lib/presentation/screens/card_binding_result_screen.dart @@ -10,12 +10,14 @@ class CardBindingResultScreen extends StatefulWidget { final CardBindingStatus status; final String title; final String cardImagePath; + final String redirectPath; // ← новое поле const CardBindingResultScreen({ super.key, required this.status, required this.title, required this.cardImagePath, + this.redirectPath = '/home/payment-methods', // сохраняем текущее поведение по умолчанию }); @override @@ -32,7 +34,7 @@ class _CardBindingResultScreenState extends State { _autoPopTimer = Timer(const Duration(seconds: 5), () { if (mounted) { 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 { text: "Попробовать снова", onTap: () { final timestamp = DateTime.now().millisecondsSinceEpoch; - context.go("/home/payment-methods?refreshId=$timestamp"); + context.go("${widget.redirectPath}?refreshId=$timestamp"); }, enabled: true, showArrows: true, @@ -142,7 +144,7 @@ class _CardBindingResultScreenState extends State { onPressed: () { final timestamp = DateTime.now().millisecondsSinceEpoch; - context.go("/home/payment-methods?refreshId=$timestamp"); + context.go("${widget.redirectPath}?refreshId=$timestamp"); }, style: TextButton.styleFrom( shape: RoundedRectangleBorder( diff --git a/lib/presentation/screens/company_details.dart b/lib/presentation/screens/company_details_screen.dart similarity index 100% rename from lib/presentation/screens/company_details.dart rename to lib/presentation/screens/company_details_screen.dart diff --git a/lib/presentation/screens/fine_list_screen.dart b/lib/presentation/screens/fine_list_screen.dart new file mode 100644 index 0000000..8a28413 --- /dev/null +++ b/lib/presentation/screens/fine_list_screen.dart @@ -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()..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( + 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().add(FetchFinesRequested()); + }, + child: const Text('Повторить'), + ), + ], + ), + ); + } + + if (state.status == FineListStatus.empty) { + return const _EmptyState(); + } + + final groupedFines = _groupByMonth(state.fines); + + return RefreshIndicator( + onRefresh: () async { + context.read().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> _groupByMonth(List fines) { + final Map> 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]}'; + } +} \ No newline at end of file diff --git a/lib/presentation/screens/fine_payment_screen.dart b/lib/presentation/screens/fine_payment_screen.dart index 4cf3a80..9709924 100644 --- a/lib/presentation/screens/fine_payment_screen.dart +++ b/lib/presentation/screens/fine_payment_screen.dart @@ -2,6 +2,7 @@ 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 '../../data/network/api_service.dart'; import '../../di/service_locator.dart'; import '../../domain/entities/payment_card.dart'; import '../../domain/usecase/add_payment_card_usecase.dart'; @@ -34,13 +35,20 @@ class FinePaymentScreen extends StatelessWidget { getFineDetailsUsecase: getIt(), payFineUsecase: getIt(), )..add(FetchFineDetails(fineId)), - child: const _FinePaymentScreenContent(), + child: _FinePaymentScreenContent(fineId: fineId), ); } } 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 photos, int initialIndex) { Navigator.of(context).push( @@ -88,24 +96,30 @@ class _FinePaymentScreenContent extends StatelessWidget { listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { if (state.status == FinePaymentStatus.success && state.paymentCompleted) { - context.go('/home'); + context.go('/home/fine-payment/$fineId/result/success'); } else if (state.status == FinePaymentStatus.failure) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage ?? 'Произошла ошибка при оплате штрафа', - style: const TextStyle(color: Colors.white), + if (state.fine != null) { + // штраф уже был загружен — значит ошибка произошла при попытке оплаты + context.go('/home/fine-payment/$fineId/result/failed'); + } else { + // штраф не удалось загрузить — остаёмся на месте, показываем снекбар + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.errorMessage ?? 'Произошла ошибка при оплате штрафа', + style: const TextStyle(color: Colors.white), + ), + backgroundColor: Colors.redAccent.withOpacity(0.9), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(20), + duration: const Duration(seconds: 3), ), - backgroundColor: Colors.redAccent.withOpacity(0.9), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - margin: const EdgeInsets.all(20), - duration: const Duration(seconds: 3), - ), - ); + ); + } } }, builder: (context, state) { @@ -122,6 +136,8 @@ class _FinePaymentScreenContent extends StatelessWidget { if (fine == null) return const SizedBox.shrink(); + final bool isPaid = fine.isPaid; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -136,7 +152,7 @@ class _FinePaymentScreenContent extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, index) { final photo = fine.photoUrls[index]; - final isNetwork = photo.startsWith('http'); + final imageUrl = _resolveImageUrl(photo); return GestureDetector( onTap: () => _openFullScreenImage( context, @@ -145,18 +161,35 @@ class _FinePaymentScreenContent extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: isNetwork - ? Image.network( - photo, - width: 110, - height: 110, - fit: BoxFit.cover, - ) - : Image.asset( - photo, + child: Image.network( + imageUrl, width: 110, height: 110, fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return SizedBox( + width: 110, + height: 110, + 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), - _buildInfoLabel('Самокат:'), + _buildInfoLabel('Номер заказа:'), _buildValueRow( 'qr_icon.png', - fine.scooterNumber, + '${fine.orderId}', ), const SizedBox(height: 20), - _buildInfoLabel('Дата:'), + _buildInfoLabel('Дата и время:'), Text( - fine.date, + _formatDateTime(fine.createdAtDateTime), style: const TextStyle( color: Colors.white, fontSize: 18, @@ -244,111 +277,112 @@ class _FinePaymentScreenContent extends StatelessWidget { const SizedBox(height: 28), - (state.useBalance || state.selectedCard != null) - ? PaymentOption( - title: state.useBalance ? 'Баланс' : "", - subtitle: state.useBalance - ? '${state.userBalance.toStringAsFixed(2)} BYN' - : '${state.selectedCard!.cardLastNumber}', - isSelected: true, - onTap: () async { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (innerContext) => BlocProvider( - create: (context) => PaymentMethodSheetBloc( - getIt(), - getIt(), - getIt(), - getIt(), - )..add(PaymentMethodSheetStarted()), - child: PaymentMethodSheet( - initialSelectedCard: state.useBalance ? null : state.selectedCard, - ), - ), - ); - - if (result != null && context.mounted) { - if (result is PaymentCard) { - context.read().add(FinePaymentCardChanged(result)); - } else if (result == 'balance') { - context.read().add(SelectFineBalancePressed()); - } - } - }, - ) - : Container( - height: 56, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Colors.white.withOpacity(0.4), - width: 1, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (innerContext) => BlocProvider( - create: (context) => PaymentMethodSheetBloc( - getIt(), - getIt(), - getIt(), - getIt(), - )..add(PaymentMethodSheetStarted()), - child: const PaymentMethodSheet(), + if (!isPaid) + (state.useBalance || state.selectedCard != null) + ? PaymentOption( + title: state.useBalance ? 'Баланс' : "", + subtitle: state.useBalance + ? '${state.userBalance.toStringAsFixed(2)} BYN' + : '${state.selectedCard!.cardLastNumber}', + isSelected: true, + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodSheetStarted()), + child: PaymentMethodSheet( + initialSelectedCard: state.useBalance ? null : state.selectedCard, ), - ); + ), + ); - if (result != null && context.mounted) { - if (result is PaymentCard) { - context.read().add(FinePaymentCardChanged(result)); - } else if (result == 'balance') { - context.read().add(SelectFineBalancePressed()); - } + if (result != null && context.mounted) { + if (result is PaymentCard) { + context.read().add(FinePaymentCardChanged(result)); + } else if (result == 'balance') { + context.read().add(SelectFineBalancePressed()); } - }, + } + }, + ) + : Container( + height: 56, + decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Способ оплаты', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, + border: Border.all( + color: Colors.white.withOpacity(0.4), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (innerContext) => BlocProvider( + create: (context) => PaymentMethodSheetBloc( + getIt(), + getIt(), + getIt(), + getIt(), + )..add(PaymentMethodSheetStarted()), + child: const PaymentMethodSheet(), + ), + ); + + if (result != null && context.mounted) { + if (result is PaymentCard) { + context.read().add(FinePaymentCardChanged(result)); + } else if (result == 'balance') { + context.read().add(SelectFineBalancePressed()); + } + } + }, + borderRadius: BorderRadius.circular(24), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Способ оплаты', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.6), - ), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.4), - ), - Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white.withOpacity(0.2), - ), - ], + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.6), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.4), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white.withOpacity(0.2), + ), + ], + ), ), ), ), ), - ), const SizedBox(height: 20), ], ); @@ -361,6 +395,11 @@ class _FinePaymentScreenContent extends StatelessWidget { padding: const EdgeInsets.all(24.0), child: BlocBuilder( builder: (context, state) { + final fine = state.fine; + if (fine == null || fine.isPaid) { + return const SizedBox.shrink(); + } + final isButtonDisabled = state.status == FinePaymentStatus.loading || (!state.useBalance && state.selectedCard == null); @@ -418,4 +457,32 @@ class _FinePaymentScreenContent extends StatelessWidget { ], ); } -} \ No newline at end of file + + 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'; + } + } +} diff --git a/lib/presentation/screens/order_history_screen.dart b/lib/presentation/screens/order_history_screen.dart index c1db72e..8558d01 100644 --- a/lib/presentation/screens/order_history_screen.dart +++ b/lib/presentation/screens/order_history_screen.dart @@ -193,7 +193,7 @@ class _EmptyState extends StatelessWidget { const SizedBox(height: 40), const Text( - 'У вас пока нет завершенных поездок.', + 'У вас пока нет завершенных поездок', textAlign: TextAlign.center, style: TextStyle( color: Colors.white, @@ -232,7 +232,7 @@ class _OrderCard extends StatelessWidget { Widget build(BuildContext context) { final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now()); 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( onTap: () { diff --git a/lib/presentation/screens/phone_screen.dart b/lib/presentation/screens/phone_screen.dart index eee2273..2877960 100644 --- a/lib/presentation/screens/phone_screen.dart +++ b/lib/presentation/screens/phone_screen.dart @@ -22,41 +22,6 @@ class PhoneScreen extends StatefulWidget { class _PhoneScreenState extends State { 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 void dispose() { _phoneController.dispose(); diff --git a/lib/presentation/screens/support_screen.dart b/lib/presentation/screens/support_screen.dart index 3bd6193..967d790 100644 --- a/lib/presentation/screens/support_screen.dart +++ b/lib/presentation/screens/support_screen.dart @@ -26,21 +26,21 @@ class SupportScreen extends StatelessWidget { LinkRow( icon: 'assets/icons/telegram.png', title: 'Telegram', - onTap: () => openLink('https://t.me/...'), + onTap: () => openLink('https://t.me/BeHappySupportt'), ), const Divider(height: 1, color: Colors.white24), const SizedBox(height: 12), LinkRow( icon: 'assets/icons/whatsapp.png', title: 'WhatsApp', - onTap: () => openLink('https://wa.me/...'), + onTap: () => openLink('https://wa.me/375257110909'), ), const Divider(height: 1, color: Colors.white24), const SizedBox(height: 12), LinkRow( icon: 'assets/icons/viber.png', title: 'Viber', - onTap: () => openLink('viber://chat?number=...'), + onTap: () => openLink('viber://chat?number=+375257110909'), ), const Divider(height: 1, color: Colors.white24), const SizedBox(height: 12), diff --git a/lib/presentation/state/fine_list_state.dart b/lib/presentation/state/fine_list_state.dart new file mode 100644 index 0000000..33ee8a3 --- /dev/null +++ b/lib/presentation/state/fine_list_state.dart @@ -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 fines; + final String? errorMessage; + + const FineListState({ + this.status = FineListStatus.loading, + this.fines = const [], + this.errorMessage, + }); + + FineListState copyWith({ + FineListStatus? status, + List? fines, + String? errorMessage, + }) { + return FineListState( + status: status ?? this.status, + fines: fines ?? this.fines, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, fines, errorMessage]; +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/fine_list_bloc.dart b/lib/presentation/viewmodel/fine_list_bloc.dart new file mode 100644 index 0000000..afdc8f0 --- /dev/null +++ b/lib/presentation/viewmodel/fine_list_bloc.dart @@ -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 { + final GetFinesUsecase getFinesUsecase; + + FineListBloc(this.getFinesUsecase) : super(const FineListState()) { + on(_onFetchFines); + on(_onRefreshFines); + } + + Future _onFetchFines( + FetchFinesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FineListStatus.loading)); + try { + final result = await getFinesUsecase(); // Возвращает Result> + + // Распаковываем Result + if (result is Success>) { + final fines = result.data ?? []; // List + emit(state.copyWith( + status: fines.isEmpty ? FineListStatus.empty : FineListStatus.success, + fines: fines, + )); + } else if (result is Failure>) { + emit(state.copyWith( + status: FineListStatus.failure, + errorMessage: result.failure.toString(), // или конкретное сообщение + )); + } + } catch (e) { + emit(state.copyWith( + status: FineListStatus.failure, + errorMessage: e.toString(), + )); + } + } + + Future _onRefreshFines( + RefreshFinesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FineListStatus.loading)); + try { + final result = await getFinesUsecase(); // Возвращает Result> + + // Распаковываем Result + if (result is Success>) { + final fines = result.data ?? []; // List + emit(state.copyWith( + status: fines.isEmpty ? FineListStatus.empty : FineListStatus.success, + fines: fines, + )); + } else if (result is Failure>) { + emit(state.copyWith( + status: FineListStatus.failure, + errorMessage: result.failure.toString(), // или конкретное сообщение + )); + } + } catch (e) { + emit(state.copyWith( + status: FineListStatus.failure, + errorMessage: e.toString(), + )); + } + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/fine_payment_bloc.dart b/lib/presentation/viewmodel/fine_payment_bloc.dart index 0b6221f..4b6706d 100644 --- a/lib/presentation/viewmodel/fine_payment_bloc.dart +++ b/lib/presentation/viewmodel/fine_payment_bloc.dart @@ -1,4 +1,6 @@ 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/pay_fine_usecase.dart'; import '../event/fine_payment_event.dart'; @@ -19,13 +21,37 @@ class FinePaymentBloc extends Bloc { } Future _onFetchFineDetails( - FetchFineDetails event, - Emitter emit, - ) async { + FetchFineDetails event, + Emitter emit, + ) async { emit(state.copyWith(status: FinePaymentStatus.loading)); try { - final fine = await getFineDetailsUsecase(event.fineId); - // emit(state.copyWith(status: FinePaymentStatus.initial, fine: fine)); + final result = await getFineDetailsUsecase(event.fineId); // Возвращает Result + + // Распаковываем Result + if (result is Success) { + 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) { + emit( + state.copyWith( + status: FinePaymentStatus.failure, + errorMessage: result.failure.toString(), // или конкретное сообщение + ), + ); + } } catch (e) { emit( state.copyWith( diff --git a/pubspec.yaml b/pubspec.yaml index 9c696a4..a1dd423 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: be_happy description: "" publish_to: 'none' -version: 1.0.0+21 +version: 1.0.0+22 environment: sdk: ^3.8.1