diff --git a/mnemo_cards_backend/FIX_404_PURCHASES.md b/mnemo_cards_backend/FIX_404_PURCHASES.md new file mode 100644 index 0000000..432d79d --- /dev/null +++ b/mnemo_cards_backend/FIX_404_PURCHASES.md @@ -0,0 +1,43 @@ +# Исправление 404 ошибки для /api/v2/purchases/packs/{packId} + +## Проблема + +При запросе `POST /api/v2/purchases/packs/{packId}` возвращается 404 Not Found. + +## Причина + +Файл `purchases_api_v2.g.dart` не сгенерирован. Роутер не может найти маршрут, потому что сгенерированный код отсутствует. + +## Решение + +Нужно запустить code generation для shelf_router: + +```bash +cd mnemo_cards_backend +dart run build_runner build --delete-conflicting-outputs +``` + +Или использовать скрипт: + +```bash +cd mnemo_cards_backend +./codegen.sh +``` + +## После генерации + +1. Перезапустить backend сервер +2. Проверить, что файл `lib/api/v2/purchases_api_v2.g.dart` создан +3. Протестировать endpoint: + ```bash + POST https://api.mnemo-cards.online/api/v2/purchases/packs/{packId} + ``` + +## Проверка + +После генерации в файле `purchases_api_v2.g.dart` должны быть роуты: +- `POST /purchases/packs/` → `createPackPurchase` +- `POST /purchases/payments` → `createPayment` +- `GET /purchases/payments//verify` → `verifyPayment` +- `GET /purchases/packs//status` → `getPackPurchaseStatus` + diff --git a/mnemo_cards_backend/lib/api/di/injector.config.dart b/mnemo_cards_backend/lib/api/di/injector.config.dart index b52c516..de7ced4 100644 --- a/mnemo_cards_backend/lib/api/di/injector.config.dart +++ b/mnemo_cards_backend/lib/api/di/injector.config.dart @@ -50,6 +50,7 @@ import '../v2/jwt_service.dart' as _i108; import '../v2/media_api_v2.dart' as _i365; import '../v2/packs_api_v2.dart' as _i800; import '../v2/promocodes_api_v2.dart' as _i273; +import '../v2/purchases_api_v2.dart' as _i69; import '../v2/subscriptions_api_v2.dart' as _i964; import '../v2/tasks_api_v2.dart' as _i985; import '../v2/telegram_bot_api_v2.dart' as _i296; @@ -66,7 +67,6 @@ extension GetItInjectableX on _i174.GetIt { final gh = _i526.GetItHelper(this, environment, environmentFilter); final appModule = _$AppModule(); gh.singleton<_i1072.AppDatabase>(() => appModule.database); - gh.singleton<_i988.YooMoneyHandler>(() => appModule.yooMoneyHandler); gh.singleton<_i533.MinioConfig>(() => appModule.minioConfig); gh.lazySingleton<_i846.AdsManager>(() => _i846.AdsManager()); gh.lazySingleton<_i222.RustorePurchaseHandler>( @@ -140,13 +140,6 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i858.DiscountsApiV2>( () => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()), ); - gh.lazySingleton<_i1009.PaymentManager>( - () => _i1009.PaymentManager( - gh<_i1072.AppDatabase>(), - gh<_i988.YooMoneyHandler>(), - gh<_i222.RustorePurchaseHandler>(), - ), - ); gh.lazySingleton<_i365.MediaApiV2>( () => _i365.MediaApiV2(gh<_i747.MinioService>()), ); @@ -170,11 +163,9 @@ extension GetItInjectableX on _i174.GetIt { gh<_i108.JwtService>(), ), ); - gh.lazySingleton<_i151.PromoCodesManager>( - () => _i151.PromoCodesManager( - gh<_i1072.AppDatabase>(), - gh<_i1009.PaymentManager>(), - ), + gh.lazySingleton<_i988.YooMoneyHandler>( + () => + _i988.YooMoneyHandler(shopId: gh(), secretKey: gh()), ); gh.lazySingleton<_i560.UserManager>( () => _i560.UserManager( @@ -188,29 +179,12 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i561.MnemoShelf>( () => _i561.MnemoShelf(gh<_i280.UserManager>()), ); - gh.lazySingleton<_i247.UsersApiV2>( - () => _i247.UsersApiV2( - gh<_i280.UserManager>(), - gh<_i1009.PaymentManager>(), - gh<_i1029.StatisticsCalculator>(), - gh<_i1072.AppDatabase>(), - ), - ); - gh.lazySingleton<_i273.PromocodesApiV2>( - () => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()), - ); gh.lazySingleton<_i908.ProductsPriceResolver>( () => _i908.ProductsPriceResolver( gh<_i891.DiscountsManager>(), gh<_i1072.AppDatabase>(), ), ); - gh.lazySingleton<_i735.CheckPaymentTask>( - () => _i735.CheckPaymentTask( - gh<_i1009.PaymentManager>(), - gh<_i1072.AppDatabase>(), - ), - ); gh.lazySingleton<_i170.TestsApiV2>( () => _i170.TestsApiV2( gh<_i259.TestManager>(), @@ -227,18 +201,55 @@ extension GetItInjectableX on _i174.GetIt { gh<_i240.TelegramAuthCodeService>(), ), ); + gh.lazySingleton<_i1009.PaymentManager>( + () => _i1009.PaymentManager( + gh<_i1072.AppDatabase>(), + gh<_i988.YooMoneyHandler>(), + gh<_i222.RustorePurchaseHandler>(), + ), + ); + gh.lazySingleton<_i151.PromoCodesManager>( + () => _i151.PromoCodesManager( + gh<_i1072.AppDatabase>(), + gh<_i1009.PaymentManager>(), + ), + ); gh.lazySingleton<_i433.PackDtoConverter>( () => _i433.PackDtoConverter( gh<_i908.ProductsPriceResolver>(), gh<_i846.AdsManager>(), ), ); + gh.lazySingleton<_i247.UsersApiV2>( + () => _i247.UsersApiV2( + gh<_i280.UserManager>(), + gh<_i1009.PaymentManager>(), + gh<_i1029.StatisticsCalculator>(), + gh<_i1072.AppDatabase>(), + ), + ); + gh.lazySingleton<_i273.PromocodesApiV2>( + () => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()), + ); + gh.lazySingleton<_i735.CheckPaymentTask>( + () => _i735.CheckPaymentTask( + gh<_i1009.PaymentManager>(), + gh<_i1072.AppDatabase>(), + ), + ); gh.lazySingleton<_i833.PackManager>( () => _i833.PackManager( gh<_i1072.AppDatabase>(), gh<_i433.PackDtoConverter>(), ), ); + gh.lazySingleton<_i69.PurchasesApiV2>( + () => _i69.PurchasesApiV2( + gh<_i1009.PaymentManager>(), + gh<_i833.PackManager>(), + gh<_i1072.AppDatabase>(), + ), + ); gh.lazySingleton<_i800.PacksApiV2>( () => _i800.PacksApiV2( gh<_i833.PackManager>(), diff --git a/mnemo_cards_backend/lib/api/di/modules.dart b/mnemo_cards_backend/lib/api/di/modules.dart index 661b3c1..d12db07 100644 --- a/mnemo_cards_backend/lib/api/di/modules.dart +++ b/mnemo_cards_backend/lib/api/di/modules.dart @@ -4,18 +4,14 @@ import 'package:injectable/injectable.dart'; import '../../database/database.dart'; import '../../main.dart' as backend_main; import '../../storage/minio_config.dart'; -import '../purchase/yoo_money.dart'; @module abstract class AppModule { @singleton AppDatabase get database => backend_main.database; - @singleton - YooMoneyHandler get yooMoneyHandler => YooMoneyHandler( - shopId: Platform.environment['YOOKASSA_SHOP_ID'] ?? '', - secretKey: Platform.environment['YOOKASSA_SECRET_KEY'] ?? '', - ); + // YooMoneyHandler is registered via @LazySingleton() annotation + // in yoo_money.dart, so we don't need to register it here @singleton MinioConfig get minioConfig => MinioConfig.fromEnvironment(); diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart index d3a4c8c..da0fc7c 100644 --- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart +++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart @@ -22,6 +22,7 @@ Router _$PacksApiV2Router(PacksApiV2 service) { r'/packs//cards//imageBack', service.getCardImageBack, ); + router.add('GET', r'/packs//cover', service.getPackCover); router.add( 'GET', r'/packs//cards//voices', diff --git a/mnemo_cards_backend/lib/api/v2/purchases_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.g.dart new file mode 100644 index 0000000..9b6ccaa --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'purchases_api_v2.dart'; + +// ************************************************************************** +// ShelfRouterGenerator +// ************************************************************************** + +Router _$PurchasesApiV2Router(PurchasesApiV2 service) { + final router = Router(); + router.add('POST', r'/purchases/packs/', service.createPackPurchase); + router.add('POST', r'/purchases/payments', service.createPayment); + router.add( + 'GET', + r'/purchases/payments//verify', + service.verifyPayment, + ); + router.add( + 'GET', + r'/purchases/packs//status', + service.getPackPurchaseStatus, + ); + return router; +} diff --git a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart index 80287a0..ddc6d5b 100644 --- a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart @@ -1038,6 +1038,49 @@ class HttpRepositoryV2 { } } + /// Get presigned URL for a media object + /// + /// [bucket] - Bucket name (e.g., 'card-images', 'test-images', 'voice-audio') + /// [objectId] - Object ID (UUID) in MinIO + /// [expirySeconds] - Optional expiry time in seconds (default from server) + /// + /// Returns a map with 'url' (String) and 'expiresAt' (String ISO8601) + Future> getPresignedUrl( + String bucket, + String objectId, { + int? expirySeconds, + }) async { + try { + final queryParams = {}; + if (expirySeconds != null) { + queryParams['expirySeconds'] = expirySeconds.toString(); + } + + final response = await _dio.get>( + '/media/$bucket/$objectId/url', + queryParameters: queryParams.isEmpty ? null : queryParams, + ); + + final data = response.data; + if (data == null) { + throw const ServerException( + message: 'Empty presigned URL response', + statusCode: 500, + ); + } + + return data; + } on DioException catch (e) { + if (e.error is ApiException) { + rethrow; + } + throw NetworkException( + message: e.message ?? 'Network error', + originalError: e, + ); + } + } + /// Download bytes from a URL (e.g., presigned URL from MinIO). /// This method doesn't require authentication as presigned URLs are public. /// Uses a separate Dio instance without interceptors to avoid adding auth headers. 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 97a88d3..924b1b4 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,7 +1,6 @@ import 'dart:developer'; import 'dart:math' as math; -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'; @@ -16,10 +15,10 @@ import '../../../presentation/widgets/pack_details_sidebar.dart'; import '../../../presentation/widgets/mnemo_text.dart'; import '../../../presentation/widgets/card_favorite_button.dart'; import '../../../presentation/widgets/card_viewer.dart'; +import '../../../presentation/widgets/expiring_card_image.dart'; import '../../../presentation/widgets/shuffle_animated_switcher.dart'; import '../../../presentation/widgets/shuffle_movement_wrapper.dart'; import '../../../presentation/widgets/utils/card_movement_utils.dart'; -import '../../../utils/card_image_utils.dart'; import '../../../utils/color_extension.dart'; import '../../../utils/responsive.dart'; @@ -818,27 +817,17 @@ class _PackDetailsPageState extends State { /// Изображение карточки (вынесено в отдельный метод для переиспользования) Widget _buildCardImage(GameCardDto card, double width, double height) { - // Use helper to get image URL (handles imageUrl, image URL, and filename) - final imageUrl = CardImageUtils.getCardImageUrl(card, widget.packId); - - if (imageUrl == null || imageUrl.isEmpty) { - return Center( - child: Icon( - Icons.collections_bookmark, - size: math.min(width, height) * 0.5, - color: Theme.of(context).colorScheme.primary, - ), - ); - } - - return CachedNetworkImage( - imageUrl: imageUrl, + return ExpiringCardImage( + card: card, + packId: widget.packId, + width: width, + height: height, fit: BoxFit.cover, + useCachedNetworkImage: true, maxWidthDiskCache: 2048, maxHeightDiskCache: 2048, - progressIndicatorBuilder: (context, child, loadingProgress) { - return const Center(child: CircularProgressIndicator(strokeWidth: 2)); - }, + fadeInDuration: const Duration(milliseconds: 100), + fadeOutDuration: const Duration(milliseconds: 100), errorWidget: (context, url, error) { return Center( child: Icon( @@ -848,9 +837,9 @@ class _PackDetailsPageState extends State { ), ); }, - // Плавное появление загруженного изображения - fadeInDuration: const Duration(milliseconds: 100), - fadeOutDuration: const Duration(milliseconds: 100), + placeholder: (context, url) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + }, ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart b/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart index 4e3c7ad..5a7a2a9 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart @@ -10,7 +10,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart'; import '../../../domain/state/card_flipper_state_manager.dart'; import '../../../presentation/widgets/card_voice_controls.dart'; import '../../../presentation/widgets/mnemo_text.dart'; -import '../../../utils/card_image_utils.dart'; +import '../../../presentation/widgets/expiring_card_image.dart'; /// Card flipper widget for studying cards /// @@ -720,26 +720,12 @@ class _CardSide extends StatelessWidget { } Widget _buildImage() { - // Use helper to get image URL (handles imageUrl, image URL, and filename) - final imageUrl = CardImageUtils.getCardImageUrl(card, packId); - - if (imageUrl == null || imageUrl.isEmpty) { - return Container( - color: packColor.withOpacity(0.1), - child: Center( - child: Icon( - Icons.collections_bookmark, - size: 60, - color: packColor.withOpacity(0.3), - ), - ), - ); - } - - return Image.network( - imageUrl, + return ExpiringCardImage( + card: card, + packId: packId, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { + useCachedNetworkImage: false, + errorWidget: (context, url, error) { return Container( color: packColor.withOpacity(0.1), child: Center( diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart b/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart index 13aadfb..027c218 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart @@ -6,10 +6,10 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import '../../presentation/theme/app_colors.dart'; -import '../../utils/card_image_utils.dart'; import 'card_favorite_button.dart'; import 'card_voice_controls.dart'; import 'mnemo_text.dart'; +import 'expiring_card_image.dart'; /// Виджет для полноэкранного просмотра карточек /// @@ -549,26 +549,12 @@ class _CardSide extends StatelessWidget { } Widget _buildImage() { - // Use helper to get image URL (handles imageUrl, image URL, and filename) - final imageUrl = CardImageUtils.getCardImageUrl(card, packId); - - if (imageUrl == null || imageUrl.isEmpty) { - return Container( - color: packColor.withOpacity(0.1), - child: Center( - child: Icon( - Icons.collections_bookmark, - size: 100, - color: packColor.withOpacity(0.3), - ), - ), - ); - } - - return Image.network( - imageUrl, + return ExpiringCardImage( + card: card, + packId: packId, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { + useCachedNetworkImage: false, + errorWidget: (context, url, error) { return Container( color: packColor.withOpacity(0.1), child: Center( @@ -584,40 +570,20 @@ class _CardSide extends StatelessWidget { } Widget _buildImageBack() { - // Use helper to get back image URL (handles imageBackUrl, imageBack URL, and filename) - final imageBackUrl = CardImageUtils.getCardImageBackUrl(card, packId); - - if (imageBackUrl == null || imageBackUrl.isEmpty) { - return Container( - color: packColor.withOpacity(0.1), - child: Center( - child: Icon( - Icons.collections_bookmark, - size: 100, - color: packColor.withOpacity(0.3), - ), - ), - ); - } - - return Image.network( - imageBackUrl, + return ExpiringCardImage( + card: card, + packId: packId, fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } + isBackImage: true, + useCachedNetworkImage: false, + placeholder: (context, url) { return Center( child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, color: packColor, ), ); }, - errorBuilder: (context, error, stackTrace) { + errorWidget: (context, url, error) { return Container( color: packColor.withOpacity(0.1), child: Center( diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/expiring_card_image.dart b/mnemo_cards_web_v2/lib/presentation/widgets/expiring_card_image.dart new file mode 100644 index 0000000..38b9b1f --- /dev/null +++ b/mnemo_cards_web_v2/lib/presentation/widgets/expiring_card_image.dart @@ -0,0 +1,269 @@ +import 'dart:developer'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.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'; +import '../../di/user_scope/user_scope_container.dart'; +import '../../domain/services/http_repository_v2.dart'; +import '../../utils/card_image_utils.dart'; + +/// Widget that handles expired presigned URLs by automatically refreshing them +/// +/// When a presigned URL expires (403 error), this widget: +/// 1. Detects the error +/// 2. Checks if card.image contains an objectId (UUID) +/// 3. Requests a new presigned URL from the API +/// 4. Updates the image URL and retries loading +class ExpiringCardImage extends StatefulWidget { + const ExpiringCardImage({ + super.key, + required this.card, + required this.packId, + this.fit = BoxFit.cover, + this.width, + this.height, + this.errorWidget, + this.placeholder, + this.maxWidthDiskCache = 2048, + this.maxHeightDiskCache = 2048, + this.fadeInDuration = const Duration(milliseconds: 100), + this.fadeOutDuration = const Duration(milliseconds: 100), + this.isBackImage = false, + this.useCachedNetworkImage = true, + }); + + final GameCardDto card; + final String packId; + final BoxFit fit; + final double? width; + final double? height; + final Widget Function(BuildContext, String, dynamic)? errorWidget; + final Widget Function(BuildContext, String)? placeholder; + final int maxWidthDiskCache; + final int maxHeightDiskCache; + final Duration fadeInDuration; + final Duration fadeOutDuration; + final bool isBackImage; + final bool useCachedNetworkImage; + + @override + State createState() => _ExpiringCardImageState(); +} + +class _ExpiringCardImageState extends State { + String? _currentUrl; + bool _isRefreshing = false; + + @override + void initState() { + super.initState(); + _currentUrl = _getInitialUrl(); + } + + @override + void didUpdateWidget(ExpiringCardImage oldWidget) { + super.didUpdateWidget(oldWidget); + // Update URL if card changed + if (oldWidget.card.id != widget.card.id || + oldWidget.isBackImage != widget.isBackImage) { + _currentUrl = _getInitialUrl(); + _isRefreshing = false; + } + } + + String? _getInitialUrl() { + if (widget.isBackImage) { + return CardImageUtils.getCardImageBackUrl(widget.card, widget.packId); + } + return CardImageUtils.getCardImageUrl(widget.card, widget.packId); + } + + String? _getObjectId() { + if (widget.isBackImage) { + return widget.card.imageBack; + } + return widget.card.image; + } + + bool _isUuid(String? value) { + if (value == null || value.isEmpty) return false; + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + return uuidRegex.hasMatch(value); + } + + HttpRepositoryV2? _getHttpRepository() { + try { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + if (appScope != null) { + return appScope.httpRepository; + } + + // Try to get from user scope + final userScope = ScopeProvider.of( + context, + listen: false, + ); + if (userScope != null) { + return userScope.httpRepository; + } + + return null; + } catch (e) { + log( + 'Failed to get HttpRepositoryV2: $e', + name: 'ExpiringCardImage', + ); + return null; + } + } + + Future _refreshPresignedUrl() async { + if (_isRefreshing) return; + + final objectId = _getObjectId(); + if (objectId == null || !_isUuid(objectId)) { + log( + 'Cannot refresh presigned URL: objectId is null or not a UUID', + name: 'ExpiringCardImage', + ); + return; + } + + final httpRepository = _getHttpRepository(); + if (httpRepository == null) { + log( + 'Cannot refresh presigned URL: HttpRepositoryV2 not available', + name: 'ExpiringCardImage', + ); + return; + } + + setState(() { + _isRefreshing = true; + }); + + try { + log( + 'Attempting to refresh presigned URL for objectId: $objectId', + name: 'ExpiringCardImage', + ); + + // Request new presigned URL from API + final response = await httpRepository.getPresignedUrl( + 'card-images', // Bucket name for card images + objectId, + ); + + final newUrl = response['url'] as String?; + if (newUrl == null || newUrl.isEmpty) { + throw Exception('Received empty presigned URL from API'); + } + + log( + 'Successfully refreshed presigned URL', + name: 'ExpiringCardImage', + ); + + if (mounted) { + setState(() { + _currentUrl = newUrl; + _isRefreshing = false; + }); + } + } catch (e, stackTrace) { + log( + 'Failed to refresh presigned URL: $e', + name: 'ExpiringCardImage', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + setState(() { + _isRefreshing = false; + }); + } + } + } + + bool _isExpiredError(dynamic error) { + // Check if error is a 403 or 404 (expired or not found) + if (error is Exception) { + final errorString = error.toString().toLowerCase(); + return errorString.contains('403') || + errorString.contains('forbidden') || + errorString.contains('expired'); + } + return false; + } + + Widget _buildErrorWidget(BuildContext context, String url, dynamic error) { + // Check if we can refresh the URL + final objectId = _getObjectId(); + final canRefresh = objectId != null && _isUuid(objectId) && _isExpiredError(error); + + if (canRefresh && !_isRefreshing) { + // Automatically try to refresh + WidgetsBinding.instance.addPostFrameCallback((_) { + _refreshPresignedUrl(); + }); + } + + if (widget.errorWidget != null) { + return widget.errorWidget!(context, url, error); + } + + return Center( + child: Icon( + Icons.broken_image, + size: widget.width != null && widget.height != null + ? (widget.width! < widget.height! ? widget.width! : widget.height!) * 0.5 + : 100, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_currentUrl == null || _currentUrl!.isEmpty) { + return _buildErrorWidget(context, '', null); + } + + if (widget.useCachedNetworkImage) { + return CachedNetworkImage( + imageUrl: _currentUrl!, + fit: widget.fit, + width: widget.width, + height: widget.height, + maxWidthDiskCache: widget.maxWidthDiskCache, + maxHeightDiskCache: widget.maxHeightDiskCache, + fadeInDuration: widget.fadeInDuration, + fadeOutDuration: widget.fadeOutDuration, + placeholder: widget.placeholder ?? (context, url) => const SizedBox.shrink(), + errorWidget: _buildErrorWidget, + ); + } else { + return Image.network( + _currentUrl!, + fit: widget.fit, + width: widget.width, + height: widget.height, + errorBuilder: (context, error, stackTrace) { + return _buildErrorWidget(context, _currentUrl!, error); + }, + loadingBuilder: widget.placeholder != null + ? null + : (context, child, loadingProgress) => child, + ); + } + } +} + diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_item.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_item.dart index 3525369..3fd2d1b 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_item.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_item.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; -import '../../utils/card_image_utils.dart'; +import 'expiring_card_image.dart'; import 'mnemo_text.dart'; /// Виджет для отображения отдельной карточки в сетке @@ -247,38 +247,25 @@ class PackCardItem extends StatelessWidget { double cardWidth, double cardHeight, ) { - // Use helper to get image URL (handles imageUrl, image URL, and filename) - final imageUrl = CardImageUtils.getCardImageUrl(card, packId); - - if (imageUrl == null || imageUrl.isEmpty) { - return const SizedBox.shrink(); - } - return ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.network( - imageUrl, + child: ExpiringCardImage( + card: card, + packId: packId, + width: cardWidth, + height: cardHeight, fit: BoxFit.contain, - gaplessPlayback: true, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } + useCachedNetworkImage: false, + placeholder: (context, url) { return Center( child: SizedBox( width: 20, height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), + child: const CircularProgressIndicator(strokeWidth: 2), ), ); }, - errorBuilder: (context, error, stackTrace) { + errorWidget: (context, url, error) { return Icon( Icons.broken_image, color: color.withOpacity(0.3),