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/media_api_v2.dart' as _i365;
|
||||||
import '../v2/packs_api_v2.dart' as _i800;
|
import '../v2/packs_api_v2.dart' as _i800;
|
||||||
import '../v2/promocodes_api_v2.dart' as _i273;
|
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/subscriptions_api_v2.dart' as _i964;
|
||||||
import '../v2/tasks_api_v2.dart' as _i985;
|
import '../v2/tasks_api_v2.dart' as _i985;
|
||||||
import '../v2/telegram_bot_api_v2.dart' as _i296;
|
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 gh = _i526.GetItHelper(this, environment, environmentFilter);
|
||||||
final appModule = _$AppModule();
|
final appModule = _$AppModule();
|
||||||
gh.singleton<_i1072.AppDatabase>(() => appModule.database);
|
gh.singleton<_i1072.AppDatabase>(() => appModule.database);
|
||||||
gh.singleton<_i988.YooMoneyHandler>(() => appModule.yooMoneyHandler);
|
|
||||||
gh.singleton<_i533.MinioConfig>(() => appModule.minioConfig);
|
gh.singleton<_i533.MinioConfig>(() => appModule.minioConfig);
|
||||||
gh.lazySingleton<_i846.AdsManager>(() => _i846.AdsManager());
|
gh.lazySingleton<_i846.AdsManager>(() => _i846.AdsManager());
|
||||||
gh.lazySingleton<_i222.RustorePurchaseHandler>(
|
gh.lazySingleton<_i222.RustorePurchaseHandler>(
|
||||||
|
|
@ -140,13 +140,6 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh.lazySingleton<_i858.DiscountsApiV2>(
|
gh.lazySingleton<_i858.DiscountsApiV2>(
|
||||||
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
|
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i1009.PaymentManager>(
|
|
||||||
() => _i1009.PaymentManager(
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
gh<_i988.YooMoneyHandler>(),
|
|
||||||
gh<_i222.RustorePurchaseHandler>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i365.MediaApiV2>(
|
gh.lazySingleton<_i365.MediaApiV2>(
|
||||||
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
|
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
|
||||||
);
|
);
|
||||||
|
|
@ -170,11 +163,9 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i108.JwtService>(),
|
gh<_i108.JwtService>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i151.PromoCodesManager>(
|
gh.lazySingleton<_i988.YooMoneyHandler>(
|
||||||
() => _i151.PromoCodesManager(
|
() =>
|
||||||
gh<_i1072.AppDatabase>(),
|
_i988.YooMoneyHandler(shopId: gh<String>(), secretKey: gh<String>()),
|
||||||
gh<_i1009.PaymentManager>(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i560.UserManager>(
|
gh.lazySingleton<_i560.UserManager>(
|
||||||
() => _i560.UserManager(
|
() => _i560.UserManager(
|
||||||
|
|
@ -188,29 +179,12 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh.lazySingleton<_i561.MnemoShelf>(
|
gh.lazySingleton<_i561.MnemoShelf>(
|
||||||
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
|
() => _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>(
|
gh.lazySingleton<_i908.ProductsPriceResolver>(
|
||||||
() => _i908.ProductsPriceResolver(
|
() => _i908.ProductsPriceResolver(
|
||||||
gh<_i891.DiscountsManager>(),
|
gh<_i891.DiscountsManager>(),
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i735.CheckPaymentTask>(
|
|
||||||
() => _i735.CheckPaymentTask(
|
|
||||||
gh<_i1009.PaymentManager>(),
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i170.TestsApiV2>(
|
gh.lazySingleton<_i170.TestsApiV2>(
|
||||||
() => _i170.TestsApiV2(
|
() => _i170.TestsApiV2(
|
||||||
gh<_i259.TestManager>(),
|
gh<_i259.TestManager>(),
|
||||||
|
|
@ -227,18 +201,55 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i240.TelegramAuthCodeService>(),
|
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>(
|
gh.lazySingleton<_i433.PackDtoConverter>(
|
||||||
() => _i433.PackDtoConverter(
|
() => _i433.PackDtoConverter(
|
||||||
gh<_i908.ProductsPriceResolver>(),
|
gh<_i908.ProductsPriceResolver>(),
|
||||||
gh<_i846.AdsManager>(),
|
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>(
|
gh.lazySingleton<_i833.PackManager>(
|
||||||
() => _i833.PackManager(
|
() => _i833.PackManager(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i433.PackDtoConverter>(),
|
gh<_i433.PackDtoConverter>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
gh.lazySingleton<_i69.PurchasesApiV2>(
|
||||||
|
() => _i69.PurchasesApiV2(
|
||||||
|
gh<_i1009.PaymentManager>(),
|
||||||
|
gh<_i833.PackManager>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
gh.lazySingleton<_i800.PacksApiV2>(
|
gh.lazySingleton<_i800.PacksApiV2>(
|
||||||
() => _i800.PacksApiV2(
|
() => _i800.PacksApiV2(
|
||||||
gh<_i833.PackManager>(),
|
gh<_i833.PackManager>(),
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,14 @@ import 'package:injectable/injectable.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../main.dart' as backend_main;
|
import '../../main.dart' as backend_main;
|
||||||
import '../../storage/minio_config.dart';
|
import '../../storage/minio_config.dart';
|
||||||
import '../purchase/yoo_money.dart';
|
|
||||||
|
|
||||||
@module
|
@module
|
||||||
abstract class AppModule {
|
abstract class AppModule {
|
||||||
@singleton
|
@singleton
|
||||||
AppDatabase get database => backend_main.database;
|
AppDatabase get database => backend_main.database;
|
||||||
|
|
||||||
@singleton
|
// YooMoneyHandler is registered via @LazySingleton() annotation
|
||||||
YooMoneyHandler get yooMoneyHandler => YooMoneyHandler(
|
// in yoo_money.dart, so we don't need to register it here
|
||||||
shopId: Platform.environment['YOOKASSA_SHOP_ID'] ?? '',
|
|
||||||
secretKey: Platform.environment['YOOKASSA_SECRET_KEY'] ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
@singleton
|
@singleton
|
||||||
MinioConfig get minioConfig => MinioConfig.fromEnvironment();
|
MinioConfig get minioConfig => MinioConfig.fromEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
|
||||||
r'/packs/<packId>/cards/<cardId>/imageBack',
|
r'/packs/<packId>/cards/<cardId>/imageBack',
|
||||||
service.getCardImageBack,
|
service.getCardImageBack,
|
||||||
);
|
);
|
||||||
|
router.add('GET', r'/packs/<packId>/cover', service.getPackCover);
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'GET',
|
||||||
r'/packs/<packId>/cards/<cardId>/voices',
|
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).
|
/// Download bytes from a URL (e.g., presigned URL from MinIO).
|
||||||
/// This method doesn't require authentication as presigned URLs are public.
|
/// This method doesn't require authentication as presigned URLs are public.
|
||||||
/// Uses a separate Dio instance without interceptors to avoid adding auth headers.
|
/// Uses a separate Dio instance without interceptors to avoid adding auth headers.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.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/mnemo_text.dart';
|
||||||
import '../../../presentation/widgets/card_favorite_button.dart';
|
import '../../../presentation/widgets/card_favorite_button.dart';
|
||||||
import '../../../presentation/widgets/card_viewer.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_animated_switcher.dart';
|
||||||
import '../../../presentation/widgets/shuffle_movement_wrapper.dart';
|
import '../../../presentation/widgets/shuffle_movement_wrapper.dart';
|
||||||
import '../../../presentation/widgets/utils/card_movement_utils.dart';
|
import '../../../presentation/widgets/utils/card_movement_utils.dart';
|
||||||
import '../../../utils/card_image_utils.dart';
|
|
||||||
import '../../../utils/color_extension.dart';
|
import '../../../utils/color_extension.dart';
|
||||||
import '../../../utils/responsive.dart';
|
import '../../../utils/responsive.dart';
|
||||||
|
|
||||||
|
|
@ -818,27 +817,17 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
|
|
||||||
/// Изображение карточки (вынесено в отдельный метод для переиспользования)
|
/// Изображение карточки (вынесено в отдельный метод для переиспользования)
|
||||||
Widget _buildCardImage(GameCardDto card, double width, double height) {
|
Widget _buildCardImage(GameCardDto card, double width, double height) {
|
||||||
// Use helper to get image URL (handles imageUrl, image URL, and filename)
|
return ExpiringCardImage(
|
||||||
final imageUrl = CardImageUtils.getCardImageUrl(card, widget.packId);
|
card: card,
|
||||||
|
packId: widget.packId,
|
||||||
if (imageUrl == null || imageUrl.isEmpty) {
|
width: width,
|
||||||
return Center(
|
height: height,
|
||||||
child: Icon(
|
|
||||||
Icons.collections_bookmark,
|
|
||||||
size: math.min(width, height) * 0.5,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
useCachedNetworkImage: true,
|
||||||
maxWidthDiskCache: 2048,
|
maxWidthDiskCache: 2048,
|
||||||
maxHeightDiskCache: 2048,
|
maxHeightDiskCache: 2048,
|
||||||
progressIndicatorBuilder: (context, child, loadingProgress) {
|
fadeInDuration: const Duration(milliseconds: 100),
|
||||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
fadeOutDuration: const Duration(milliseconds: 100),
|
||||||
},
|
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|
@ -848,9 +837,9 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// Плавное появление загруженного изображения
|
placeholder: (context, url) {
|
||||||
fadeInDuration: const Duration(milliseconds: 100),
|
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||||
fadeOutDuration: const Duration(milliseconds: 100),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart';
|
||||||
import '../../../domain/state/card_flipper_state_manager.dart';
|
import '../../../domain/state/card_flipper_state_manager.dart';
|
||||||
import '../../../presentation/widgets/card_voice_controls.dart';
|
import '../../../presentation/widgets/card_voice_controls.dart';
|
||||||
import '../../../presentation/widgets/mnemo_text.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
|
/// Card flipper widget for studying cards
|
||||||
///
|
///
|
||||||
|
|
@ -720,26 +720,12 @@ class _CardSide extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
// Use helper to get image URL (handles imageUrl, image URL, and filename)
|
return ExpiringCardImage(
|
||||||
final imageUrl = CardImageUtils.getCardImageUrl(card, packId);
|
card: card,
|
||||||
|
packId: 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,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
useCachedNetworkImage: false,
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
return Container(
|
return Container(
|
||||||
color: packColor.withOpacity(0.1),
|
color: packColor.withOpacity(0.1),
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
import '../../presentation/theme/app_colors.dart';
|
import '../../presentation/theme/app_colors.dart';
|
||||||
import '../../utils/card_image_utils.dart';
|
|
||||||
import 'card_favorite_button.dart';
|
import 'card_favorite_button.dart';
|
||||||
import 'card_voice_controls.dart';
|
import 'card_voice_controls.dart';
|
||||||
import 'mnemo_text.dart';
|
import 'mnemo_text.dart';
|
||||||
|
import 'expiring_card_image.dart';
|
||||||
|
|
||||||
/// Виджет для полноэкранного просмотра карточек
|
/// Виджет для полноэкранного просмотра карточек
|
||||||
///
|
///
|
||||||
|
|
@ -549,26 +549,12 @@ class _CardSide extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
// Use helper to get image URL (handles imageUrl, image URL, and filename)
|
return ExpiringCardImage(
|
||||||
final imageUrl = CardImageUtils.getCardImageUrl(card, packId);
|
card: card,
|
||||||
|
packId: 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,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
useCachedNetworkImage: false,
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
return Container(
|
return Container(
|
||||||
color: packColor.withOpacity(0.1),
|
color: packColor.withOpacity(0.1),
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|
@ -584,40 +570,20 @@ class _CardSide extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageBack() {
|
Widget _buildImageBack() {
|
||||||
// Use helper to get back image URL (handles imageBackUrl, imageBack URL, and filename)
|
return ExpiringCardImage(
|
||||||
final imageBackUrl = CardImageUtils.getCardImageBackUrl(card, packId);
|
card: card,
|
||||||
|
packId: 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,
|
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
isBackImage: true,
|
||||||
if (loadingProgress == null) {
|
useCachedNetworkImage: false,
|
||||||
return child;
|
placeholder: (context, url) {
|
||||||
}
|
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: loadingProgress.expectedTotalBytes != null
|
|
||||||
? loadingProgress.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: null,
|
|
||||||
color: packColor,
|
color: packColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorWidget: (context, url, error) {
|
||||||
return Container(
|
return Container(
|
||||||
color: packColor.withOpacity(0.1),
|
color: packColor.withOpacity(0.1),
|
||||||
child: Center(
|
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:flutter/material.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.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';
|
import 'mnemo_text.dart';
|
||||||
|
|
||||||
/// Виджет для отображения отдельной карточки в сетке
|
/// Виджет для отображения отдельной карточки в сетке
|
||||||
|
|
@ -247,38 +247,25 @@ class PackCardItem extends StatelessWidget {
|
||||||
double cardWidth,
|
double cardWidth,
|
||||||
double cardHeight,
|
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(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Image.network(
|
child: ExpiringCardImage(
|
||||||
imageUrl,
|
card: card,
|
||||||
|
packId: packId,
|
||||||
|
width: cardWidth,
|
||||||
|
height: cardHeight,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
gaplessPlayback: true,
|
useCachedNetworkImage: false,
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
placeholder: (context, url) {
|
||||||
if (loadingProgress == null) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
return Center(
|
return Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
strokeWidth: 2,
|
|
||||||
value: loadingProgress.expectedTotalBytes != null
|
|
||||||
? loadingProgress.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorWidget: (context, url, error) {
|
||||||
return Icon(
|
return Icon(
|
||||||
Icons.broken_image,
|
Icons.broken_image,
|
||||||
color: color.withOpacity(0.3),
|
color: color.withOpacity(0.3),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue