exp image
Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Mobile App CI / test (push) Has been cancelled
Web App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled
Web App CI / build (push) Has been cancelled

This commit is contained in:
Dmitry 2026-01-03 16:34:26 +03:00
parent 92eae620da
commit cbab096626
11 changed files with 464 additions and 149 deletions

View file

@ -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/<packId>``createPackPurchase`
- `POST /purchases/payments``createPayment`
- `GET /purchases/payments/<paymentId>/verify``verifyPayment`
- `GET /purchases/packs/<packId>/status``getPackPurchaseStatus`

View file

@ -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<String>(), secretKey: gh<String>()),
);
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>(),

View file

@ -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();

View file

@ -22,6 +22,7 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
r'/packs/<packId>/cards/<cardId>/imageBack',
service.getCardImageBack,
);
router.add('GET', r'/packs/<packId>/cover', service.getPackCover);
router.add(
'GET',
r'/packs/<packId>/cards/<cardId>/voices',

View file

@ -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/<packId>', service.createPackPurchase);
router.add('POST', r'/purchases/payments', service.createPayment);
router.add(
'GET',
r'/purchases/payments/<paymentId>/verify',
service.verifyPayment,
);
router.add(
'GET',
r'/purchases/packs/<packId>/status',
service.getPackPurchaseStatus,
);
return router;
}

View file

@ -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<Map<String, dynamic>> getPresignedUrl(
String bucket,
String objectId, {
int? expirySeconds,
}) async {
try {
final queryParams = <String, dynamic>{};
if (expirySeconds != null) {
queryParams['expirySeconds'] = expirySeconds.toString();
}
final response = await _dio.get<Map<String, dynamic>>(
'/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.

View file

@ -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<PackDetailsPage> {
/// Изображение карточки (вынесено в отдельный метод для переиспользования)
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<PackDetailsPage> {
),
);
},
// Плавное появление загруженного изображения
fadeInDuration: const Duration(milliseconds: 100),
fadeOutDuration: const Duration(milliseconds: 100),
placeholder: (context, url) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
},
);
}

View file

@ -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(

View file

@ -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(

View file

@ -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<ExpiringCardImage> createState() => _ExpiringCardImageState();
}
class _ExpiringCardImageState extends State<ExpiringCardImage> {
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<AppScopeContainer>(
context,
listen: false,
);
if (appScope != null) {
return appScope.httpRepository;
}
// Try to get from user scope
final userScope = ScopeProvider.of<UserScopeContainer>(
context,
listen: false,
);
if (userScope != null) {
return userScope.httpRepository;
}
return null;
} catch (e) {
log(
'Failed to get HttpRepositoryV2: $e',
name: 'ExpiringCardImage',
);
return null;
}
}
Future<void> _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,
);
}
}
}

View file

@ -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),