From b95a3f048d5191ab470ef2cd6b9f515b91e6a80a Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 8 Jan 2026 22:56:01 +0300 Subject: [PATCH] payment --- .../lib/api/purchase/payment_manager.dart | 15 ++ .../lib/api/purchase/yoo_money.dart | 31 ++- .../lib/api/v2/purchases_api_v2.dart | 21 +- .../lib/database/daos/payment_dao.dart | 15 ++ .../lib/di/user_scope/user_scope.dart | 4 + .../di/user_scope/user_scope_container.dart | 12 + .../events/purchase_event_broadcaster.dart | 111 +++++++++ .../pages/pack_details/pack_details_page.dart | 50 ++++ .../payment_return/payment_return_page.dart | 228 ++++++++++++++++++ .../pages/purchase/purchase_page.dart | 15 ++ .../lib/presentation/router/app_router.dart | 13 + .../widgets/pack_details_header.dart | 63 ++++- .../lib/utils/card_image_utils.dart | 4 + 13 files changed, 563 insertions(+), 19 deletions(-) create mode 100644 mnemo_cards_web_v2/lib/domain/events/purchase_event_broadcaster.dart create mode 100644 mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart diff --git a/mnemo_cards_backend/lib/api/purchase/payment_manager.dart b/mnemo_cards_backend/lib/api/purchase/payment_manager.dart index a785b5b..0f94815 100644 --- a/mnemo_cards_backend/lib/api/purchase/payment_manager.dart +++ b/mnemo_cards_backend/lib/api/purchase/payment_manager.dart @@ -75,6 +75,19 @@ class PaymentManager { return payment?.toDto(); } + /// Получить последний платеж для пользователя и пака + /// Используется при возврате с ЮКассы без paymentId + Future getLatestPaymentForUserAndPack({ + required String userId, + required String packId, + }) async { + final payment = await _db.paymentDao.getLatestPaymentForUserAndPack( + userId: userId, + packId: packId, + ); + return payment?.toDto(); + } + /// Выдать продукт пользователю (для промокодов и других бесплатных активаций) Future grantProductToUser( String userId, @@ -349,6 +362,7 @@ class PaymentManager { required String description, required String userId, List products = const [], + String? packId, }) async { print('🔍 PaymentManager.createYookassaUrl: Starting'); print( @@ -362,6 +376,7 @@ class PaymentManager { amount: amount, description: description, userId: userId, + packId: packId, ); print( '✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}', diff --git a/mnemo_cards_backend/lib/api/purchase/yoo_money.dart b/mnemo_cards_backend/lib/api/purchase/yoo_money.dart index aab97a4..5b7baaa 100644 --- a/mnemo_cards_backend/lib/api/purchase/yoo_money.dart +++ b/mnemo_cards_backend/lib/api/purchase/yoo_money.dart @@ -78,6 +78,7 @@ class YooMoneyHandler { required String amount, required String description, required String userId, + String? packId, }) async { if (!isConfigured) { throw Exception( @@ -101,7 +102,7 @@ class YooMoneyHandler { // Build return URL for payment confirmation // According to spec: ReturnUrl - URL where user returns after payment // Max 2048 characters per spec - final returnUrl = _buildReturnUrl(userId); + final returnUrl = _buildReturnUrl(userId, packId: packId); // Generate idempotence key for request // According to spec: Idempotence-Key header (required) @@ -117,6 +118,7 @@ class YooMoneyHandler { 'capture': true, // Auto-capture payment when succeeded 'metadata': { 'userId': userId, + if (packId != null) 'packId': packId, 'createdAt': DateTime.now().toIso8601String(), }, }; @@ -152,6 +154,11 @@ class YooMoneyHandler { final paymentStatus = paymentData['status'] as String? ?? 'pending'; + // Update return URL with paymentId (if needed) + // Note: YooKassa doesn't allow updating return_url after creation, + // so we include paymentId in the initial return URL via metadata or query params + // For now, we'll pass paymentId through the return URL query params + // Extract confirmation URL from payment response // According to spec: confirmation.confirmation_url for redirect type String? confirmationUrl; @@ -252,15 +259,31 @@ class YooMoneyHandler { /// Build return URL for payment confirmation /// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL - String _buildReturnUrl(String userId) { + String _buildReturnUrl( + String userId, { + String? packId, + String? paymentId, + }) { + // Construct query parameters + final params = { + 'userId': userId, + if (packId != null) 'packId': packId, + if (paymentId != null) 'paymentId': paymentId, + }; + final query = params.entries + .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') + .join('&'); + // Use configured return URL base if available final returnUrlBase = _returnUrlBase; if (returnUrlBase != null && returnUrlBase.isNotEmpty) { - return '$returnUrlBase?userId=$userId'; + // Remove trailing slash and query params from base + final base = returnUrlBase.split('?').first.replaceAll(RegExp(r'/$'), ''); + return '$base?$query'; } // Default to web app URL // This should be configured via environment variable in production - return 'https://mnemo-cards.online/payment/return?userId=$userId'; + return 'https://mnemo-cards.online/payment/return?$query'; } } diff --git a/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart index db33ad3..e5c9dd1 100644 --- a/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart @@ -145,6 +145,7 @@ class PurchasesApiV2 { description: 'Покупка пакета: ${pack.title}', userId: user.id!, products: products, + packId: packId, ); print( '✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}', @@ -291,6 +292,7 @@ class PurchasesApiV2 { description: description, userId: user.id!, products: products, + packId: productType == MnemoCardsProductType.pack ? productId : null, ); // Build return URL for payment verification @@ -328,8 +330,23 @@ class PurchasesApiV2 { return _badRequest('productId query parameter is required'); } + // If paymentId is "latest", find the latest payment for this user and product + String actualPaymentId = paymentId; + if (paymentId == 'latest' && user.id != null) { + final latestPayment = await _paymentManager + .getLatestPaymentForUserAndPack( + userId: user.id!, + packId: productId, + ); + if (latestPayment == null || latestPayment.externalToken == null) { + return _notFound('No payment found for this pack'); + } + actualPaymentId = latestPayment.externalToken!; + } + // Check payment status - final isSuccess = await _paymentManager.checkYookassaPayment(paymentId); + final isSuccess = + await _paymentManager.checkYookassaPayment(actualPaymentId); // Get product information MnemoCardsProductDto? product; @@ -346,7 +363,7 @@ class PurchasesApiV2 { } return _ok({ - 'paymentId': paymentId, + 'paymentId': actualPaymentId, 'status': isSuccess ? 'verified' : 'pending', 'result': isSuccess, if (product != null) 'product': product.toJson(), diff --git a/mnemo_cards_backend/lib/database/daos/payment_dao.dart b/mnemo_cards_backend/lib/database/daos/payment_dao.dart index 1237a3c..ab6f696 100644 --- a/mnemo_cards_backend/lib/database/daos/payment_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/payment_dao.dart @@ -206,6 +206,21 @@ class PaymentDao extends DatabaseAccessor return await query.map((row) => row.read(countExpr)!).getSingle(); } + /// Получить последний платеж пользователя для продукта (пака) + /// Используется для получения paymentId при возврате с ЮКассы + Future getLatestPaymentForUserAndPack({ + required String userId, + required String packId, + }) async { + final query = selectActive() + ..where((p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%')) + ..orderBy([(p) => OrderingTerm.desc(p.createdAt)]) + ..limit(1); + + final results = await query.get(); + return results.isNotEmpty ? results.first : null; + } + /// Подсчитать платежи пользователя (только активные) Future countPaymentsByUserId(String userId) async { final countExpr = payments.id.count(); diff --git a/mnemo_cards_web_v2/lib/di/user_scope/user_scope.dart b/mnemo_cards_web_v2/lib/di/user_scope/user_scope.dart index 9bb05ad..ae8d960 100644 --- a/mnemo_cards_web_v2/lib/di/user_scope/user_scope.dart +++ b/mnemo_cards_web_v2/lib/di/user_scope/user_scope.dart @@ -12,6 +12,7 @@ import '../../domain/services/image_cache_service.dart'; import '../../domain/services/pack_progress_service.dart'; import '../../domain/services/statistics_service.dart'; import '../../domain/services/purchases_service.dart'; +import '../../domain/events/purchase_event_broadcaster.dart'; import '../../domain/state/ads_reward_state_manager.dart'; import '../../domain/state/favorites_state_manager.dart'; import '../../domain/state/games_state_manager.dart'; @@ -82,6 +83,9 @@ abstract class UserScope implements Scope { /// Statistics state manager StatisticsStateManager get statisticsStateManager; + + /// Purchase event broadcaster for purchase notifications + PurchaseEventBroadcaster get purchaseEventBroadcaster; } /// Interface for parent scope (AppScope) diff --git a/mnemo_cards_web_v2/lib/di/user_scope/user_scope_container.dart b/mnemo_cards_web_v2/lib/di/user_scope/user_scope_container.dart index 13e4b6b..c5d2edc 100644 --- a/mnemo_cards_web_v2/lib/di/user_scope/user_scope_container.dart +++ b/mnemo_cards_web_v2/lib/di/user_scope/user_scope_container.dart @@ -9,6 +9,7 @@ import '../../domain/services/purchases_service.dart'; import '../../domain/services/pack_progress_service.dart'; import '../../domain/services/statistics_service.dart'; import '../../domain/services/ads_reward_service.dart'; +import '../../domain/events/purchase_event_broadcaster.dart'; import '../../domain/state/ads_reward_state_manager.dart'; import '../../domain/state/favorites_state_manager.dart'; import '../../domain/state/games_state_manager.dart'; @@ -80,6 +81,13 @@ class UserScopeContainer extends ChildScopeContainer // Statistics Module late final statisticsModuleDep = dep(() => StatisticsModule(this)); + // Purchase Event Broadcaster + late final purchaseEventBroadcasterDep = rawAsyncDep( + () => PurchaseEventBroadcaster(), + init: (_) async {}, + dispose: (broadcaster) async => broadcaster.dispose(), + ); + @override UserStateManager get userStateManager => userStateManagerDep.get; @@ -146,6 +154,10 @@ class UserScopeContainer extends ChildScopeContainer StatisticsStateManager get statisticsStateManager => statisticsModuleDep.get.statisticsStateManager; + @override + PurchaseEventBroadcaster get purchaseEventBroadcaster => + purchaseEventBroadcasterDep.get; + // Provide httpRepository from parent HttpRepositoryV2 get httpRepository => parent.httpRepository; } diff --git a/mnemo_cards_web_v2/lib/domain/events/purchase_event_broadcaster.dart b/mnemo_cards_web_v2/lib/domain/events/purchase_event_broadcaster.dart new file mode 100644 index 0000000..c4f590a --- /dev/null +++ b/mnemo_cards_web_v2/lib/domain/events/purchase_event_broadcaster.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:developer'; + +/// Типы событий покупки +enum PurchaseEventType { + /// Покупка начата (платеж создан) + started, + + /// Покупка завершена успешно + completed, + + /// Покупка отменена или ошибка + failed, +} + +/// Событие покупки +class PurchaseEvent { + final PurchaseEventType type; + final String? packId; + final String? paymentId; + final String? message; + final DateTime timestamp; + + const PurchaseEvent({ + required this.type, + this.packId, + this.paymentId, + this.message, + required this.timestamp, + }); + + @override + String toString() => + 'PurchaseEvent(${type.name}, pack: $packId, payment: $paymentId)'; +} + +/// Broadcaster для событий покупки +/// +/// Используется для уведомления различных частей приложения +/// о событиях покупки (начало, завершение, ошибка) +class PurchaseEventBroadcaster { + final _controller = StreamController.broadcast(); + + /// Stream событий покупки + Stream get stream => _controller.stream; + + /// Отправить событие о начале покупки + void onPurchaseStarted({ + required String packId, + required String paymentId, + }) { + log( + 'Purchase started: packId=$packId, paymentId=$paymentId', + name: 'PurchaseEventBroadcaster', + ); + _controller.add( + PurchaseEvent( + type: PurchaseEventType.started, + packId: packId, + paymentId: paymentId, + timestamp: DateTime.now(), + ), + ); + } + + /// Отправить событие о завершении покупки + void onPurchaseCompleted({ + required String packId, + required String paymentId, + }) { + log( + 'Purchase completed: packId=$packId, paymentId=$paymentId', + name: 'PurchaseEventBroadcaster', + ); + _controller.add( + PurchaseEvent( + type: PurchaseEventType.completed, + packId: packId, + paymentId: paymentId, + timestamp: DateTime.now(), + ), + ); + } + + /// Отправить событие об ошибке покупки + void onPurchaseFailed({ + required String packId, + String? paymentId, + String? message, + }) { + log( + 'Purchase failed: packId=$packId, paymentId=$paymentId, error=$message', + name: 'PurchaseEventBroadcaster', + ); + _controller.add( + PurchaseEvent( + type: PurchaseEventType.failed, + packId: packId, + paymentId: paymentId, + message: message, + timestamp: DateTime.now(), + ), + ); + } + + /// Закрыть broadcaster (вызывать в dispose) + void dispose() { + _controller.close(); + } +} + diff --git a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart index 924b1b4..901b83b 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:math' as math; @@ -7,6 +8,7 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:yx_scope_flutter/yx_scope_flutter.dart'; import '../../../di/app_scope/app_scope_container.dart'; +import '../../../domain/events/purchase_event_broadcaster.dart'; import '../../../presentation/theme/app_colors.dart'; import '../../../presentation/widgets/pack_card_item.dart'; import '../../../presentation/widgets/pack_details_header.dart'; @@ -51,6 +53,7 @@ class _PackDetailsPageState extends State { Map _previousCardIndexById = {}; int _lastAnimatedShuffleKey = 0; bool _isNavigatingToPurchase = false; + StreamSubscription? _purchaseEventSubscription; @override void initState() { @@ -58,6 +61,53 @@ class _PackDetailsPageState extends State { _loadPack(); _loadTests(); _loadPackProgress(); + _subscribeToPurchaseEvents(); + } + + @override + void dispose() { + _purchaseEventSubscription?.cancel(); + super.dispose(); + } + + void _subscribeToPurchaseEvents() { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + final userScope = appScope?.userScopeHolder.scope; + + if (userScope == null) { + log( + 'User scope not available, skipping purchase event subscription', + name: 'PackDetailsPage', + ); + return; + } + + _purchaseEventSubscription = + userScope.purchaseEventBroadcaster.stream.listen( + (event) { + // Перезагрузить данные только если событие относится к текущему паку + if (event.packId == widget.packId && + event.type == PurchaseEventType.completed) { + log( + 'Purchase completed for pack ${widget.packId}, reloading data', + name: 'PackDetailsPage', + ); + _loadPack(); + _loadPackProgress(); + } + }, + onError: (Object error, StackTrace stackTrace) { + log( + 'Error in purchase event stream', + error: error, + stackTrace: stackTrace, + name: 'PackDetailsPage', + ); + }, + ); } Future _loadPack() async { diff --git a/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart new file mode 100644 index 0000000..697f0b6 --- /dev/null +++ b/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart @@ -0,0 +1,228 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:yx_scope_flutter/yx_scope_flutter.dart'; + +import '../../../di/app_scope/app_scope_container.dart'; + +/// Страница обработки возврата с ЮКассы +/// +/// Автоматически верифицирует платеж и перенаправляет на страницу пака +/// Query parameters: +/// - userId: ID пользователя +/// - paymentId: ID платежа в ЮКассе +/// - packId: ID купленного пака +class PaymentReturnPage extends StatefulWidget { + const PaymentReturnPage({ + required this.queryParams, + super.key, + }); + + final Map queryParams; + + @override + State createState() => _PaymentReturnPageState(); +} + +class _PaymentReturnPageState extends State { + String? _errorMessage; + bool _isVerifying = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _verifyAndRedirect(); + }); + } + + Future _verifyAndRedirect() async { + var paymentId = widget.queryParams['paymentId']; + final packId = widget.queryParams['packId']; + final userId = widget.queryParams['userId']; + + log( + 'Processing payment return: paymentId=$paymentId, packId=$packId, userId=$userId', + name: 'PaymentReturnPage', + ); + + // Валидация параметров + if (packId == null) { + setState(() { + _errorMessage = + 'Некорректные параметры возврата. Пожалуйста, проверьте свои покупки в профиле.'; + _isVerifying = false; + }); + return; + } + + // Если paymentId отсутствует, используем "latest" для поиска последнего платежа + if (paymentId == null) { + log( + 'PaymentId not provided, will use latest payment for pack', + name: 'PaymentReturnPage', + ); + paymentId = 'latest'; + } + + try { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + final userScope = appScope?.userScopeHolder.scope; + + if (userScope == null) { + throw Exception('User scope не доступен'); + } + + // Верифицировать платеж + log('Verifying payment: $paymentId', name: 'PaymentReturnPage'); + final result = await userScope.purchasesService.verifyPayment( + paymentId: paymentId, + productId: packId, + productType: MnemoCardsProductType.pack, + ); + + if (!mounted) return; + + if (result.isSuccess) { + log( + 'Payment verified successfully, emitting event', + name: 'PaymentReturnPage', + ); + + // Отправить событие о завершении покупки + userScope.purchaseEventBroadcaster.onPurchaseCompleted( + packId: packId, + paymentId: paymentId, + ); + + // Показать успешное сообщение + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Покупка успешно завершена! 🎉'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + + // Небольшая задержка для показа сообщения + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) return; + + // Перенаправить на страницу пака + log('Redirecting to pack: $packId', name: 'PaymentReturnPage'); + context.go('/pack/$packId'); + } else { + // Платеж не подтвержден + log( + 'Payment verification failed: ${result.status}', + name: 'PaymentReturnPage', + ); + setState(() { + _errorMessage = + 'Платеж еще не подтвержден (${result.status}). Пожалуйста, подождите несколько минут и проверьте доступ к паку позже.'; + _isVerifying = false; + }); + } + } catch (e, s) { + log( + 'Error verifying payment', + error: e, + stackTrace: s, + name: 'PaymentReturnPage', + ); + + if (!mounted) return; + + setState(() { + _errorMessage = + 'Ошибка проверки платежа: ${e.toString()}. Попробуйте проверить доступ к паку позже или обратитесь в поддержку.'; + _isVerifying = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Обработка платежа'), + ), + body: Center( + child: _isVerifying + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + 'Проверяем платеж...', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Это может занять несколько секунд', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 24), + Text( + 'Не удалось подтвердить платеж', + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + _errorMessage ?? 'Произошла ошибка', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: () => context.go('/home'), + child: const Text('На главную'), + ), + const SizedBox(width: 16), + FilledButton( + onPressed: () { + setState(() { + _isVerifying = true; + _errorMessage = null; + }); + _verifyAndRedirect(); + }, + child: const Text('Попробовать снова'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + diff --git a/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart index 3b4a36a..fb76cf3 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/purchase/purchase_page.dart @@ -96,6 +96,21 @@ class _PurchasePageState extends State { return; } + // Emit purchase started event + if (mounted) { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + final userScope = appScope?.userScopeHolder.scope; + if (userScope != null) { + userScope.purchaseEventBroadcaster.onPurchaseStarted( + packId: widget.packId, + paymentId: payment.paymentId, + ); + } + } + // Open payment URL final uri = Uri.parse(payment.purchaseUrl); if (await canLaunchUrl(uri)) { diff --git a/mnemo_cards_web_v2/lib/presentation/router/app_router.dart b/mnemo_cards_web_v2/lib/presentation/router/app_router.dart index 08d33c2..36d1a7f 100644 --- a/mnemo_cards_web_v2/lib/presentation/router/app_router.dart +++ b/mnemo_cards_web_v2/lib/presentation/router/app_router.dart @@ -7,6 +7,7 @@ import '../pages/game/game_page.dart'; import '../pages/games/games_page.dart'; import '../pages/home/home_page.dart'; import '../pages/pack_details/pack_details_page.dart'; +import '../pages/payment_return/payment_return_page.dart'; import '../pages/playground/playground_page.dart'; import '../pages/profile/profile_page.dart'; import '../pages/purchase/purchase_page.dart'; @@ -133,6 +134,18 @@ GoRouter createAppRouter({required UserScopeHolder userScopeHolder}) { }, ), + // Payment Return Page (from YooKassa) + GoRoute( + path: '/payment/return', + name: 'payment-return', + pageBuilder: (context, state) { + final queryParams = state.uri.queryParameters; + return MaterialPage( + child: PaymentReturnPage(queryParams: queryParams), + ); + }, + ), + // Test Page GoRoute( path: '/test/:testId', diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart index b4e11c6..e1d2fae 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart @@ -1,7 +1,9 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import '../../utils/card_image_utils.dart'; import '../../utils/color_extension.dart'; /// Кастомный заголовок для страницы деталей пака @@ -94,19 +96,7 @@ class PackDetailsHeader extends StatelessWidget { const SizedBox(width: 16), // Pack icon or image - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: packColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.collections_bookmark, - color: packColor, - size: 28, - ), - ), + _buildPackImage(packColor), const SizedBox(width: 16), @@ -150,6 +140,53 @@ class PackDetailsHeader extends StatelessWidget { ); } + Widget _buildPackImage(Color packColor) { + final imageUrl = CardImageUtils.getPackCoverUrlFromDto(pack); + + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: packColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: 48, + height: 48, + progressIndicatorBuilder: (context, url, progress) { + return Center( + child: CircularProgressIndicator( + value: progress.progress, + strokeWidth: 2, + ), + ); + }, + errorWidget: (context, url, error) { + return Center( + child: Icon( + Icons.collections_bookmark, + color: packColor, + size: 28, + ), + ); + }, + ), + ) + : Center( + child: Icon( + Icons.collections_bookmark, + color: packColor, + size: 28, + ), + ), + ); + } + Widget _buildProgressSection( BuildContext context, Color packColor, diff --git a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart index 8081797..2b1dba6 100644 --- a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart +++ b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart @@ -80,4 +80,8 @@ class CardImageUtils { // Fallback: use helper method return ApiConfigV2.getPackCoverUrl(pack.id); } + + static String? getPackCoverUrlFromDto(CardPackDto pack) { + return ApiConfigV2.getPackCoverUrl(pack.id); + } }