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
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:
parent
92eae620da
commit
cbab096626
11 changed files with 464 additions and 149 deletions
43
mnemo_cards_backend/FIX_404_PURCHASES.md
Normal file
43
mnemo_cards_backend/FIX_404_PURCHASES.md
Normal 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`
|
||||
|
||||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
24
mnemo_cards_backend/lib/api/v2/purchases_api_v2.g.dart
Normal file
24
mnemo_cards_backend/lib/api/v2/purchases_api_v2.g.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue