stuff
Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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
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

This commit is contained in:
Dmitry 2025-12-20 22:27:32 +03:00
parent dc01b02508
commit 1bfec9d511
16 changed files with 268 additions and 365 deletions

View file

@ -303,7 +303,7 @@ class AdminPacksApiV2 {
'title': dto.title, 'title': dto.title,
'subtitle': dto.subtitle, 'subtitle': dto.subtitle,
'color': dto.color, 'color': dto.color,
'cover': dto.imageBase64, 'cover': dto.imageUrl,
'cards': dto.cards, 'cards': dto.cards,
'enabled': pack.enabled, 'enabled': pack.enabled,
'order': pack.order, 'order': pack.order,

View file

@ -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/<packId>/cover')
@OpenApiRouteHttp()
Future<Response> 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 /api/v2/packs/{packId}/cards/{cardId}/voices
/// Get card voices metadata /// Get card voices metadata
/// Returns JSON list of VoiceDto /// Returns JSON list of VoiceDto

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart'; 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' import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'
hide VoiceModel; hide VoiceModel;
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -10,6 +11,22 @@ extension CardPackToDto on CardPack {
final hasAccess = final hasAccess =
user != null && (user.purchases.contains(id.toString()) || user.admin); 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( return CardPackPreviewDto(
id: id.toString(), id: id.toString(),
title: title, title: title,
@ -17,7 +34,7 @@ extension CardPackToDto on CardPack {
cards: size, cards: size,
tests: null, // TODO: Get tests count if needed tests: null, // TODO: Get tests count if needed
price: price, price: price,
imageBase64: cover, imageUrl: coverUrl,
color: color, color: color,
isAvailable: hasAccess, isAvailable: hasAccess,
tip: null, tip: null,

View file

@ -36,6 +36,21 @@ class PackDtoConverter {
final canOpenForAdVal = !available && canOpenForAd(model.id); 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( return CardPackPreviewDto(
id: model.id.toString(), id: model.id.toString(),
title: model.title, title: model.title,
@ -43,8 +58,7 @@ class PackDtoConverter {
cards: model.cards.length, cards: model.cards.length,
tests: model.tests.length, tests: model.tests.length,
price: price, price: price,
imageBase64: imageUrl: coverUrl,
null, // Legacy: covers should be stored in MinIO, not read from disk
color: model.color, color: model.color,
isAvailable: available, isAvailable: available,
// trail: 'asset:icons/gift.png', // trail: 'asset:icons/gift.png',

View file

@ -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<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? 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 <CardPackModel>[];
});
// 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<List<CardPackModel>> _getPacks() async {
return await backend_main.isar.cardPackModels
.filter()
.enabledEqualTo(true)
.findAll();
}
Future<CardPackModel?> getPack(Id id) async {
return await backend_main.isar.cardPackModels.get(id);
}
Future<List<GameCardModel>> getCards(Id packId) async {
return await backend_main.isar.gameCardModels
.filter()
.packIdEqualTo(packId)
.findAll();
}
Future<GameCardModel?> getCard(Id id) async {
return await backend_main.isar.gameCardModels.get(id);
}
Future<VoiceModel?> getVoice(Id id) async {
return await backend_main.isar.voiceModels.get(id);
}
Future<List<VoiceModel>> getVoices(Id cardId) async {
return await backend_main.isar.voiceModels
.filter()
.cardIdEqualTo(cardId)
.findAll();
}
Future<CardPackDto> 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 = <VoiceModel>[];
for (final card in cards) {
voices.addAll(await getVoices(card.id!));
}
return await packDtoConverter.toCardPackDto(pack, cards, voices, userModel);
}
Future<List<String>> 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 = <String>[];
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<Map<String, Uint8List>> getPackImages(Id packId) async {
final cards = await getCards(packId);
final empty = <String, Uint8List>{};
return cards.fold(empty, (Map<String, Uint8List> 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');
}

View file

@ -16,7 +16,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto {
final int? cards; final int? cards;
final int? tests; final int? tests;
final String? price; final String? price;
final String? imageBase64; final String? imageUrl;
final String? color; final String? color;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool isAvailable; final bool isAvailable;
@ -30,7 +30,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto {
required this.cards, required this.cards,
required this.tests, required this.tests,
required this.price, required this.price,
required this.imageBase64, required this.imageUrl,
required this.color, required this.color,
required this.isAvailable, required this.isAvailable,
required this.tip, required this.tip,
@ -48,7 +48,7 @@ class CardPackPreviewDto implements MnemoCardsProductDto {
cards, cards,
tests, tests,
price, price,
imageBase64, imageUrl,
color, color,
isAvailable, isAvailable,
version, version,

View file

@ -19,7 +19,7 @@ abstract class _$CardPackPreviewDtoCWProxy {
CardPackPreviewDto price(String? price); CardPackPreviewDto price(String? price);
CardPackPreviewDto imageBase64(String? imageBase64); CardPackPreviewDto imageUrl(String? imageUrl);
CardPackPreviewDto color(String? color); CardPackPreviewDto color(String? color);
@ -43,7 +43,7 @@ abstract class _$CardPackPreviewDtoCWProxy {
int? cards, int? cards,
int? tests, int? tests,
String? price, String? price,
String? imageBase64, String? imageUrl,
String? color, String? color,
bool isAvailable, bool isAvailable,
PackTip? tip, PackTip? tip,
@ -77,8 +77,7 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy {
CardPackPreviewDto price(String? price) => call(price: price); CardPackPreviewDto price(String? price) => call(price: price);
@override @override
CardPackPreviewDto imageBase64(String? imageBase64) => CardPackPreviewDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl);
call(imageBase64: imageBase64);
@override @override
CardPackPreviewDto color(String? color) => call(color: color); CardPackPreviewDto color(String? color) => call(color: color);
@ -108,7 +107,7 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy {
Object? cards = const $CopyWithPlaceholder(), Object? cards = const $CopyWithPlaceholder(),
Object? tests = const $CopyWithPlaceholder(), Object? tests = const $CopyWithPlaceholder(),
Object? price = const $CopyWithPlaceholder(), Object? price = const $CopyWithPlaceholder(),
Object? imageBase64 = const $CopyWithPlaceholder(), Object? imageUrl = const $CopyWithPlaceholder(),
Object? color = const $CopyWithPlaceholder(), Object? color = const $CopyWithPlaceholder(),
Object? isAvailable = const $CopyWithPlaceholder(), Object? isAvailable = const $CopyWithPlaceholder(),
Object? tip = const $CopyWithPlaceholder(), Object? tip = const $CopyWithPlaceholder(),
@ -139,10 +138,10 @@ class _$CardPackPreviewDtoCWProxyImpl implements _$CardPackPreviewDtoCWProxy {
? _value.price ? _value.price
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: price as String?, : price as String?,
imageBase64: imageBase64 == const $CopyWithPlaceholder() imageUrl: imageUrl == const $CopyWithPlaceholder()
? _value.imageBase64 ? _value.imageUrl
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: imageBase64 as String?, : imageUrl as String?,
color: color == const $CopyWithPlaceholder() color: color == const $CopyWithPlaceholder()
? _value.color ? _value.color
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
@ -184,7 +183,7 @@ CardPackPreviewDto _$CardPackPreviewDtoFromJson(Map<String, dynamic> json) =>
cards: (json['cards'] as num?)?.toInt(), cards: (json['cards'] as num?)?.toInt(),
tests: (json['tests'] as num?)?.toInt(), tests: (json['tests'] as num?)?.toInt(),
price: json['price'] as String?, price: json['price'] as String?,
imageBase64: json['imageBase64'] as String?, imageUrl: json['imageUrl'] as String?,
color: json['color'] as String?, color: json['color'] as String?,
isAvailable: json['isAvailable'] as bool? ?? false, isAvailable: json['isAvailable'] as bool? ?? false,
tip: json['tip'] == null tip: json['tip'] == null
@ -201,7 +200,7 @@ Map<String, dynamic> _$CardPackPreviewDtoToJson(CardPackPreviewDto instance) =>
'cards': ?instance.cards, 'cards': ?instance.cards,
'tests': ?instance.tests, 'tests': ?instance.tests,
'price': ?instance.price, 'price': ?instance.price,
'imageBase64': ?instance.imageBase64, 'imageUrl': ?instance.imageUrl,
'color': ?instance.color, 'color': ?instance.color,
'isAvailable': instance.isAvailable, 'isAvailable': instance.isAvailable,
'tip': ?instance.tip?.toJson(), 'tip': ?instance.tip?.toJson(),

View file

@ -147,6 +147,10 @@ class ApiConfigV2 {
static String packCardImageBack(String packId, String cardId) => static String packCardImageBack(String packId, String cardId) =>
'/packs/$packId/cards/$cardId/imageBack'; '/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 /api/v2/voice/{voiceId}
/// Get voice file /// Get voice file
static String voiceFile(String voiceId) => '/voice/$voiceId'; static String voiceFile(String voiceId) => '/voice/$voiceId';
@ -243,6 +247,12 @@ class ApiConfigV2 {
return '$baseUrl${packCardImageBack(packId, cardId)}'; 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 /// Get voice file URL by id
/// Returns: http://baseUrl/api/v2/voice/{voiceId} /// Returns: http://baseUrl/api/v2/voice/{voiceId}
static String getVoiceFileUrl(String voiceId) { static String getVoiceFileUrl(String voiceId) {

View file

@ -1,11 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Service for caching decoded images /// Service for caching decoded images
/// ///
/// Stores decoded MemoryImage instances to avoid repeated base64 decoding /// Stores decoded MemoryImage instances for in-memory caching.
/// and improve performance. Used for pack cover images. /// Note: Network images are automatically cached by CachedNetworkImage,
/// this service is for local MemoryImage instances if needed.
class ImageCacheService { class ImageCacheService {
final Map<String, MemoryImage> _cache = {}; final Map<String, MemoryImage> _cache = {};
@ -20,31 +19,6 @@ class ImageCacheService {
_cache[key] = image; _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 /// Clear all cached images
void clear() { void clear() {
_cache.clear(); _cache.clear();

View file

@ -178,7 +178,6 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
), ),
), ),
], ],
),
); );
}, },
); );

View file

@ -160,7 +160,6 @@ class _MatchWidgetState extends State<MatchWidget> {
), ),
), ),
], ],
),
); );
}, },
); );

View file

@ -238,7 +238,6 @@ class _MatrixWidgetState extends State<MatrixWidget>
), ),
), ),
], ],
),
); );
} }

View file

@ -1,11 +1,9 @@
import 'dart:convert'; 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';
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/color_extension.dart';
import '../../utils/pack_tip_extension.dart'; import '../../utils/pack_tip_extension.dart';
@ -139,31 +137,7 @@ class PackCard extends StatelessWidget {
Widget _buildPackImage(BuildContext context, Color packColor) { Widget _buildPackImage(BuildContext context, Color packColor) {
final imageSize = cardHeight - 2; final imageSize = cardHeight - 2;
final imageUrl = CardImageUtils.getPackCoverUrl(pack);
// Try to get image cache service
final userScope = ScopeProvider.of<UserScope>(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);
return Container( return Container(
width: imageSize, width: imageSize,
@ -171,20 +145,41 @@ class PackCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: packColor.withOpacity(0.1), color: packColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(11),
image: imageProvider != null
? DecorationImage(image: imageProvider, fit: BoxFit.cover)
: null,
), ),
// Показываем иконку только если нет изображения child: imageUrl != null
child: imageProvider == null ? ClipRRect(
? Center( 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( child: Icon(
Icons.collections_bookmark, Icons.collections_bookmark,
size: 40, size: 40,
color: packColor, color: packColor,
), ),
) ),
: null,
); );
} }

View file

@ -1,9 +1,9 @@
import 'dart:convert'; 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';
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/color_extension.dart';
import 'package:mnemo_cards_web_v2/utils/pack_tip_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) { Widget _buildPackImage(BuildContext context, Color packColor) {
final imageUrl = CardImageUtils.getPackCoverUrl(pack);
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration(color: packColor.withOpacity(0.1)), decoration: BoxDecoration(color: packColor.withOpacity(0.1)),
// Показываем изображение или иконку child: imageUrl != null
child: pack.imageBase64 != null ? CachedNetworkImage(
? Padding( imageUrl: imageUrl,
padding: const EdgeInsets.all(0.0),
child: Image.memory(
base64Decode(pack.imageBase64!),
fit: BoxFit.contain, 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( : Center(
child: Icon( child: Icon(

View file

@ -60,4 +60,31 @@ class CardImageUtils {
return null; 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);
}
} }

View file

@ -39,7 +39,7 @@ void main() {
cards: 10, cards: 10,
tests: 5, tests: 5,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,
@ -60,7 +60,7 @@ void main() {
cards: 10, cards: 10,
tests: 5, tests: 5,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,
@ -73,7 +73,7 @@ void main() {
cards: 15, cards: 15,
tests: 7, tests: 7,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,
@ -95,7 +95,7 @@ void main() {
cards: 10, cards: 10,
tests: 5, tests: 5,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,
@ -116,7 +116,7 @@ void main() {
cards: 10, cards: 10,
tests: 5, tests: 5,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,
@ -137,7 +137,7 @@ void main() {
cards: 10, cards: 10,
tests: 5, tests: 5,
price: null, price: null,
imageBase64: null, imageUrl: null,
color: null, color: null,
isAvailable: true, isAvailable: true,
tip: null, tip: null,