diff --git a/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart index bd9940a..fa8a9c6 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart @@ -303,7 +303,7 @@ class AdminPacksApiV2 { 'title': dto.title, 'subtitle': dto.subtitle, 'color': dto.color, - 'cover': dto.imageBase64, + 'cover': dto.imageUrl, 'cards': dto.cards, 'enabled': pack.enabled, 'order': pack.order, diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart index 1e65f8d..989bb17 100644 --- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart @@ -594,6 +594,61 @@ class PacksApiV2 { } } + /// GET /api/v2/packs/{packId}/cover + /// Get pack cover image + /// Returns redirect to presigned URL or image file + /// + /// Cover images are accessible for enabled packs even without authentication + /// to allow image preview in public pack listings + @Route.get('/packs//cover') + @OpenApiRouteHttp() + Future getPackCover( + Request request, + String packId, + ) async { + try { + if (packId.isEmpty) { + return _badRequest('Invalid pack ID'); + } + + // Check if pack exists and is enabled + // We allow access to covers for enabled packs even without auth + // to support image previews in public listings + final pack = await _db.packDao.getPackById(packId); + if (pack == null || !pack.enabled) { + return _notFound('Pack not found or not enabled'); + } + + final coverValue = pack.cover?.trim(); + if (coverValue == null || coverValue.isEmpty) { + return _notFound('Cover image not found'); + } + + // If it's a valid UUID (object ID in MinIO), redirect to presigned URL + if (_isValidUuid(coverValue)) { + final presignedUrl = await _minioService.getPresignedUrl( + bucket: MinioConfig.cardImagesBucket, + objectId: coverValue, + ); + if (presignedUrl != null) { + return Response.found(presignedUrl); + } + return _notFound('Cover image not found in storage'); + } + + // Remote URL: redirect + if (CardImageStorage.isRemoteUrl(coverValue)) { + return Response.found(coverValue); + } + + // Not a UUID and not a remote URL - image not found + return _notFound('Cover image not found'); + } catch (e, s) { + print('Error fetching pack cover: $e\n$s'); + return _internalServerError('Error loading cover image'); + } + } + /// GET /api/v2/packs/{packId}/cards/{cardId}/voices /// Get card voices metadata /// Returns JSON list of VoiceDto diff --git a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart index f6178ca..5b1c9cf 100644 --- a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart +++ b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' as drift; import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/packs/card_image_storage.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; @@ -10,6 +11,22 @@ extension CardPackToDto on CardPack { final hasAccess = user != null && (user.purchases.contains(id.toString()) || user.admin); + // Generate cover image URL + String? coverUrl; + if (cover != null && cover!.isNotEmpty) { + final coverValue = cover!.trim(); + + // If it's already a remote URL, use it directly + if (CardImageStorage.isRemoteUrl(coverValue)) { + coverUrl = coverValue; + } + // If it's a UUID (object ID in MinIO) or any other value, use API endpoint + // The endpoint will handle redirecting to presigned URL or returning the image + else if (coverValue.isNotEmpty) { + coverUrl = '/api/v2/packs/${id.toString()}/cover'; + } + } + return CardPackPreviewDto( id: id.toString(), title: title, @@ -17,7 +34,7 @@ extension CardPackToDto on CardPack { cards: size, tests: null, // TODO: Get tests count if needed price: price, - imageBase64: cover, + imageUrl: coverUrl, color: color, isAvailable: hasAccess, tip: null, diff --git a/mnemo_cards_backend/lib/packs/pack_dto_converter.dart b/mnemo_cards_backend/lib/packs/pack_dto_converter.dart index f43d090..30e3183 100644 --- a/mnemo_cards_backend/lib/packs/pack_dto_converter.dart +++ b/mnemo_cards_backend/lib/packs/pack_dto_converter.dart @@ -36,6 +36,21 @@ class PackDtoConverter { final canOpenForAdVal = !available && canOpenForAd(model.id); + // Generate cover image URL + String? coverUrl; + if (model.cover != null && model.cover!.isNotEmpty) { + final coverValue = model.cover!.trim(); + + // If it's already a remote URL, use it directly + if (coverValue.startsWith('http://') || coverValue.startsWith('https://')) { + coverUrl = coverValue; + } + // If it's a UUID or any other value, use API endpoint + else if (coverValue.isNotEmpty) { + coverUrl = '/api/v2/packs/${model.id.toString()}/cover'; + } + } + return CardPackPreviewDto( id: model.id.toString(), title: model.title, @@ -43,8 +58,7 @@ class PackDtoConverter { cards: model.cards.length, tests: model.tests.length, price: price, - imageBase64: - null, // Legacy: covers should be stored in MinIO, not read from disk + imageUrl: coverUrl, color: model.color, isAvailable: available, // trail: 'asset:icons/gift.png', diff --git a/mnemo_cards_backend/lib/packs/pack_manager_temp.dart.backup b/mnemo_cards_backend/lib/packs/pack_manager_temp.dart.backup deleted file mode 100644 index 552b989..0000000 --- a/mnemo_cards_backend/lib/packs/pack_manager_temp.dart.backup +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:image/image.dart'; -import 'package:injectable/injectable.dart'; -import 'package:isar/isar.dart'; -import 'package:mnemo_cards_backend/database/database.dart'; -import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel; -import 'package:archive/archive.dart'; -import 'package:mnemo_cards_backend/packs/card_dto_extension.dart'; -import 'package:mnemo_cards_backend/packs/card_pack_model_extension.dart'; -import 'package:mnemo_cards_common/mnemo_cards_common.dart'; - -import '../main.dart' as backend_main; -import 'pack_dto_converter.dart'; -import 'pack_manager_extensions.dart'; - -@lazySingleton -class PackManager { - final AppDatabase _db; - final PackDtoConverter packDtoConverter; - - PackManager(this._db, this.packDtoConverter); - - Future> listPacksPreviews( - UserModel? userModel, - Map? params, - ) async { - // TODO: Full migration - replace CardPackModel with Drift CardPack - // Need to update PackDtoConverter to work with Drift models - // For now using isar temporarily until full migration - final models = await backend_main.database.transaction(() async { - // Temporary: using isar through backend_main until conversion complete - return []; - }); - // TODO: Use _db.packDao.getAllPacks() and convert CardPack to CardPackModel - // Or update packDtoConverter to accept CardPack - models.sort((p, n) => p.order.compareTo(n.order)); - return (await Future.wait(models.map( - (model) async { - final dto = - await packDtoConverter.toCardPackPreviewDto(model, userModel); - if (!model.enabled) { - return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}'); - } - return dto; - }, - ))) - .toList(); - } - - Future> _getPacks() async { - return await backend_main.isar.cardPackModels - .filter() - .enabledEqualTo(true) - .findAll(); - } - - Future getPack(Id id) async { - return await backend_main.isar.cardPackModels.get(id); - } - - Future> getCards(Id packId) async { - return await backend_main.isar.gameCardModels - .filter() - .packIdEqualTo(packId) - .findAll(); - } - - Future getCard(Id id) async { - return await backend_main.isar.gameCardModels.get(id); - } - - Future getVoice(Id id) async { - return await backend_main.isar.voiceModels.get(id); - } - - Future> getVoices(Id cardId) async { - return await backend_main.isar.voiceModels - .filter() - .cardIdEqualTo(cardId) - .findAll(); - } - - Future getPackDto(Id id, UserModel? userModel) async { - final pack = await getPack(id); - if (pack == null) { - throw StateError('Pack not found'); - } - - final cards = await getCards(id); - final voices = []; - - for (final card in cards) { - voices.addAll(await getVoices(card.id!)); - } - - return await packDtoConverter.toCardPackDto(pack, cards, voices, userModel); - } - - Future> getPackPreviewImages(Id packId) async { - final pack = await getPack(packId); - if (pack == null) { - throw StateError('Pack not found'); - } - - final cards = await getCards(packId); - final previewCardIds = pack.previewCardsOrder.take(6); - - final previewCards = cards - .where((card) => previewCardIds.contains(card.id)) - .take(6) - .toList(); - - final images = []; - for (final card in previewCards) { - if (card.image.isNotEmpty) { - try { - final image = await card.image.base64Image; - images.add(image); - } catch (e, s) { - log('error while reading image', error: e, stackTrace: s); - } - } - } - - // If we don't have enough preview images, fill with empty strings - while (images.length < 6) { - images.add(''); - } - - return images; - } - - Future> getPackImages(Id packId) async { - final cards = await getCards(packId); - - final empty = {}; - return cards.fold(empty, (Map map, card) { - if (card.image.isNotEmpty) { - try { - return map - ..addAll({ - card.id.toString(): - File('${PackManagerUtils.assetsDirectory.path}/cards/${card.image}') - .readAsBytesSync(), - }); - } catch (e, s) { - log('error while reading image', error: e, stackTrace: s); - return map; - } - } - return map; - }); - } -} - -// Static utility functions for PackManager -enum _ImageSize { - big, - medium, - small, - extraSmall, -} - -class PackManagerUtils { - static Directory get assetsDirectory { - String mainPath = Platform.resolvedExecutable; - if ((Platform.isMacOS || Platform.isLinux) && - !Platform.script.toString().contains('StudioProjects')) { - mainPath = mainPath.substring(0, mainPath.lastIndexOf("/")); - var dir = Directory("$mainPath/../data"); - if (dir.existsSync()) { - return dir; - } - dir = Directory("$mainPath/data"); - if (dir.existsSync()) { - return dir; - } - throw Exception('No asset dir! $mainPath'); - } - - if (Platform.isMacOS) { - mainPath = mainPath.substring(0, mainPath.lastIndexOf("/")); - return Directory( - '/Users/dmitry/StudioProjects/mnemo_cards/mnemo_cards_backend/data'); - } else if (Platform.isWindows) { - mainPath = mainPath.substring(0, mainPath.lastIndexOf("\\")); - return Directory("$mainPath/data/flutter_assets/data"); - } else { - return Directory(''); - } - } - - static File getResizedFile(String id, String type, _ImageSize size) => - File('${assetsDirectory.path}/$type/${size.name}/$id'); -} \ No newline at end of file diff --git a/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.dart b/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.dart index 272cd2d..34f6257 100644 --- a/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.dart +++ b/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.dart @@ -16,7 +16,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto { final int? cards; final int? tests; final String? price; - final String? imageBase64; + final String? imageUrl; final String? color; @JsonKey(defaultValue: false) final bool isAvailable; @@ -30,7 +30,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto { required this.cards, required this.tests, required this.price, - required this.imageBase64, + required this.imageUrl, required this.color, required this.isAvailable, required this.tip, @@ -48,7 +48,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto { cards, tests, price, - imageBase64, + imageUrl, color, isAvailable, version, diff --git a/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.g.dart b/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.g.dart index 14f3650..b5fb581 100644 --- a/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.g.dart +++ b/mnemo_cards_common/lib/src/dtos/packs/card_pack_preview_dto.g.dart @@ -19,7 +19,7 @@ abstract class _$CardPackPreviewDtoCWProxy { CardPackPreviewDto price(String? price); - CardPackPreviewDto imageBase64(String? imageBase64); + CardPackPreviewDto imageUrl(String? imageUrl); CardPackPreviewDto color(String? color); @@ -43,7 +43,7 @@ abstract class _$CardPackPreviewDtoCWProxy { int? cards, int? tests, String? price, - String? imageBase64, + String? imageUrl, String? color, bool isAvailable, PackTip? tip, @@ -77,8 +77,7 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy { CardPackPreviewDto price(String? price) => call(price: price); @override - CardPackPreviewDto imageBase64(String? imageBase64) => - call(imageBase64: imageBase64); + CardPackPreviewDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl); @override CardPackPreviewDto color(String? color) => call(color: color); @@ -108,7 +107,7 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy { Object? cards = const $CopyWithPlaceholder(), Object? tests = const $CopyWithPlaceholder(), Object? price = const $CopyWithPlaceholder(), - Object? imageBase64 = const $CopyWithPlaceholder(), + Object? imageUrl = const $CopyWithPlaceholder(), Object? color = const $CopyWithPlaceholder(), Object? isAvailable = const $CopyWithPlaceholder(), Object? tip = const $CopyWithPlaceholder(), @@ -139,10 +138,10 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy { ? _value.price // ignore: cast_nullable_to_non_nullable : price as String?, - imageBase64: imageBase64 == const $CopyWithPlaceholder() - ? _value.imageBase64 + imageUrl: imageUrl == const $CopyWithPlaceholder() + ? _value.imageUrl // ignore: cast_nullable_to_non_nullable - : imageBase64 as String?, + : imageUrl as String?, color: color == const $CopyWithPlaceholder() ? _value.color // ignore: cast_nullable_to_non_nullable @@ -184,7 +183,7 @@ CardPackPreviewDto _$CardPackPreviewDtoFromJson(Map json) => cards: (json['cards'] as num?)?.toInt(), tests: (json['tests'] as num?)?.toInt(), price: json['price'] as String?, - imageBase64: json['imageBase64'] as String?, + imageUrl: json['imageUrl'] as String?, color: json['color'] as String?, isAvailable: json['isAvailable'] as bool? ?? false, tip: json['tip'] == null @@ -201,7 +200,7 @@ Map _$CardPackPreviewDtoToJson(CardPackPreviewDto instance) => 'cards': ?instance.cards, 'tests': ?instance.tests, 'price': ?instance.price, - 'imageBase64': ?instance.imageBase64, + 'imageUrl': ?instance.imageUrl, 'color': ?instance.color, 'isAvailable': instance.isAvailable, 'tip': ?instance.tip?.toJson(), diff --git a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart index 9dcb586..bd2b954 100644 --- a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart @@ -147,6 +147,10 @@ class ApiConfigV2 { static String packCardImageBack(String packId, String cardId) => '/packs/$packId/cards/$cardId/imageBack'; + /// GET /api/v2/packs/{packId}/cover + /// Get pack cover image + static String packCover(String packId) => '/packs/$packId/cover'; + /// GET /api/v2/voice/{voiceId} /// Get voice file static String voiceFile(String voiceId) => '/voice/$voiceId'; @@ -243,6 +247,12 @@ class ApiConfigV2 { return '$baseUrl${packCardImageBack(packId, cardId)}'; } + /// Get pack cover image URL for a specific pack + /// Returns: http://baseUrl/api/v2/packs/{packId}/cover + static String getPackCoverUrl(String packId) { + return '$baseUrl${packCover(packId)}'; + } + /// Get voice file URL by id /// Returns: http://baseUrl/api/v2/voice/{voiceId} static String getVoiceFileUrl(String voiceId) { diff --git a/mnemo_cards_web_v2/lib/domain/services/image_cache_service.dart b/mnemo_cards_web_v2/lib/domain/services/image_cache_service.dart index 5e856eb..8a635f8 100644 --- a/mnemo_cards_web_v2/lib/domain/services/image_cache_service.dart +++ b/mnemo_cards_web_v2/lib/domain/services/image_cache_service.dart @@ -1,11 +1,10 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; /// Service for caching decoded images /// -/// Stores decoded MemoryImage instances to avoid repeated base64 decoding -/// and improve performance. Used for pack cover images. +/// Stores decoded MemoryImage instances for in-memory caching. +/// Note: Network images are automatically cached by CachedNetworkImage, +/// this service is for local MemoryImage instances if needed. class ImageCacheService { final Map _cache = {}; @@ -20,31 +19,6 @@ class ImageCacheService { _cache[key] = image; } - /// Decode base64 string and cache the resulting image - /// Returns null if decoding fails - MemoryImage? putBase64(String key, String base64String) { - try { - final bytes = base64Decode(base64String); - final image = MemoryImage(bytes); - _cache[key] = image; - return image; - } catch (e) { - // Invalid base64 or image data - return null; - } - } - - /// Decode base64 string and cache with automatic key generation - /// Key format: 'pack_`packId`' - MemoryImage? putPackImage(String packId, String base64String) { - return putBase64('pack_$packId', base64String); - } - - /// Get pack image by pack ID - MemoryImage? getPackImage(String packId) { - return get('pack_$packId'); - } - /// Clear all cached images void clear() { _cache.clear(); diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart index eb5d3a4..b7fde3f 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart @@ -167,18 +167,17 @@ class _InputLettersWidgetState extends State { SizedBox(height: spacingAfterGrid), ], - // Submit button - ElevatedButton.icon( - onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null, - icon: const Icon(Icons.send), - label: const Text('Submit Answer'), - style: ElevatedButton.styleFrom( - minimumSize: Size(200.w, 48.h), - textStyle: TextStyle(fontSize: 16.sp), - ), + // Submit button + ElevatedButton.icon( + onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null, + icon: const Icon(Icons.send), + label: const Text('Submit Answer'), + style: ElevatedButton.styleFrom( + minimumSize: Size(200.w, 48.h), + textStyle: TextStyle(fontSize: 16.sp), ), - ], - ), + ), + ], ); }, ); diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart index 26b63fa..8b64c52 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart @@ -143,28 +143,27 @@ class _MatchWidgetState extends State { isLeft: false, ), ), - ], - ), - ), - - SizedBox(height: spacingAfterColumns), - - // Submit button - ElevatedButton.icon( - onPressed: _canSubmit ? _submitAnswer : null, - icon: const Icon(Icons.check_circle), - label: const Text('Submit Answer'), - style: ElevatedButton.styleFrom( - minimumSize: Size(200.w, 48.h), - textStyle: TextStyle(fontSize: 16.sp), + ], ), - ), - ], ), - ); - }, - ); - } + + SizedBox(height: spacingAfterColumns), + + // Submit button + ElevatedButton.icon( + onPressed: _canSubmit ? _submitAnswer : null, + icon: const Icon(Icons.check_circle), + label: const Text('Submit Answer'), + style: ElevatedButton.styleFrom( + minimumSize: Size(200.w, 48.h), + textStyle: TextStyle(fontSize: 16.sp), + ), + ), + ], + ); + }, + ); +} Widget _buildColumn(List items, {required bool isLeft}) { // Fixed sizes for consistent layout diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart index ee5b9d2..153c4b4 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart @@ -203,42 +203,41 @@ class _MatrixWidgetState extends State SizedBox(height: spacingBetween), Container( padding: EdgeInsets.symmetric( - horizontal: targetPaddingH, - vertical: targetPaddingV, - ), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(24.r), - border: Border.all(color: colorScheme.primary, width: 3), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.question.text != null && - widget.question.text!.isNotEmpty) - Text( - widget.question.text!, - style: textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - if (widget.question.text != null && - widget.question.text!.isNotEmpty) - SizedBox(height: 8.h), - _buildTargetContent(context), - ], - ), + horizontal: targetPaddingH, + vertical: targetPaddingV, ), - ], - ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(24.r), + border: Border.all(color: colorScheme.primary, width: 3), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.question.text != null && + widget.question.text!.isNotEmpty) + Text( + widget.question.text!, + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (widget.question.text != null && + widget.question.text!.isNotEmpty) + SizedBox(height: 8.h), + _buildTargetContent(context), + ], + ), + ), + ], ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart index d1225dc..46f8181 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart @@ -1,11 +1,9 @@ -import 'dart:convert'; - +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; -import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart'; -import 'package:yx_scope_flutter/yx_scope_flutter.dart'; +import '../../utils/card_image_utils.dart'; import '../../utils/color_extension.dart'; import '../../utils/pack_tip_extension.dart'; @@ -139,31 +137,7 @@ class PackCard extends StatelessWidget { Widget _buildPackImage(BuildContext context, Color packColor) { final imageSize = cardHeight - 2; - - // Try to get image cache service - final userScope = ScopeProvider.of(context, listen: false); - - MemoryImage? cachedImage; - - // Try to use cached image first - if (userScope != null && pack.imageBase64 != null) { - cachedImage = userScope.imageCacheService.getPackImage(pack.id); - - // If not cached yet, decode and cache it - if (cachedImage == null) { - cachedImage = userScope.imageCacheService.putPackImage( - pack.id, - pack.imageBase64!, - ); - } - } - - // Fallback: decode directly if no cache available - final imageProvider = - cachedImage ?? - (pack.imageBase64 != null - ? MemoryImage(base64Decode(pack.imageBase64!)) - : null); + final imageUrl = CardImageUtils.getPackCoverUrl(pack); return Container( width: imageSize, @@ -171,20 +145,41 @@ class PackCard extends StatelessWidget { decoration: BoxDecoration( color: packColor.withOpacity(0.1), borderRadius: BorderRadius.circular(11), - image: imageProvider != null - ? DecorationImage(image: imageProvider, fit: BoxFit.cover) - : null, ), - // Показываем иконку только если нет изображения - child: imageProvider == null - ? Center( + child: imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(11), + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: imageSize, + height: imageSize, + progressIndicatorBuilder: (context, url, progress) { + return Center( + child: CircularProgressIndicator( + value: progress.progress, + strokeWidth: 2, + ), + ); + }, + errorWidget: (context, url, error) { + return Center( + child: Icon( + Icons.broken_image, + size: 40, + color: packColor.withOpacity(0.3), + ), + ); + }, + ), + ) + : Center( child: Icon( Icons.collections_bookmark, size: 40, color: packColor, ), - ) - : null, + ), ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart index e2a7bec..48bcd7a 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart @@ -1,9 +1,9 @@ -import 'dart:convert'; - +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/utils/card_image_utils.dart'; import 'package:mnemo_cards_web_v2/utils/color_extension.dart'; import 'package:mnemo_cards_web_v2/utils/pack_tip_extension.dart'; @@ -124,17 +124,32 @@ class PackCardVertical extends StatelessWidget { } Widget _buildPackImage(BuildContext context, Color packColor) { + final imageUrl = CardImageUtils.getPackCoverUrl(pack); + return Container( alignment: Alignment.center, decoration: BoxDecoration(color: packColor.withOpacity(0.1)), - // Показываем изображение или иконку - child: pack.imageBase64 != null - ? Padding( - padding: const EdgeInsets.all(0.0), - child: Image.memory( - base64Decode(pack.imageBase64!), - fit: BoxFit.contain, - ), + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.contain, + progressIndicatorBuilder: (context, url, progress) { + return Center( + child: CircularProgressIndicator( + value: progress.progress, + strokeWidth: 2, + ), + ); + }, + errorWidget: (context, url, error) { + return Center( + child: Icon( + Icons.broken_image, + size: 64, + color: packColor.withOpacity(0.3), + ), + ); + }, ) : Center( child: Icon( diff --git a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart index 1b6b4ac..cc29441 100644 --- a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart +++ b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart @@ -60,4 +60,31 @@ class CardImageUtils { return null; } + + /// Get the display URL for a pack's cover image + /// + /// Priority: + /// 1. imageUrl if it's a full URL (starts with http/https) + /// 2. imageUrl if it's a relative path - convert to full API endpoint URL + /// + /// Returns null if no cover image is available + static String? getPackCoverUrl(CardPackPreviewDto pack) { + final imageUrl = pack.imageUrl; + if (imageUrl == null || imageUrl.isEmpty) { + return null; + } + + // If it's already a full URL, use it + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + return imageUrl; + } + + // If it's a relative path (starts with /api/), convert to full URL + if (imageUrl.startsWith('/')) { + return '${ApiConfigV2.baseUrl}$imageUrl'; + } + + // Fallback: assume it's a relative path and prepend base URL + return ApiConfigV2.getPackCoverUrl(pack.id); + } } diff --git a/mnemo_cards_web_v2/test/domain/services/pack_manager_test.dart b/mnemo_cards_web_v2/test/domain/services/pack_manager_test.dart index 73fa70f..c23b9d9 100644 --- a/mnemo_cards_web_v2/test/domain/services/pack_manager_test.dart +++ b/mnemo_cards_web_v2/test/domain/services/pack_manager_test.dart @@ -39,7 +39,7 @@ void main() { cards: 10, tests: 5, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null, @@ -60,7 +60,7 @@ void main() { cards: 10, tests: 5, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null, @@ -73,7 +73,7 @@ void main() { cards: 15, tests: 7, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null, @@ -95,7 +95,7 @@ void main() { cards: 10, tests: 5, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null, @@ -116,7 +116,7 @@ void main() { cards: 10, tests: 5, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null, @@ -137,7 +137,7 @@ void main() { cards: 10, tests: 5, price: null, - imageBase64: null, + imageUrl: null, color: null, isAvailable: true, tip: null,