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,
'subtitle': dto.subtitle,
'color': dto.color,
'cover': dto.imageBase64,
'cover': dto.imageUrl,
'cards': dto.cards,
'enabled': pack.enabled,
'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 card voices metadata
/// Returns JSON list of VoiceDto

View file

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

View file

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

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? 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,

View file

@ -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<String, dynamic> 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<String, dynamic> _$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(),

View file

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

View file

@ -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<String, MemoryImage> _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();

View file

@ -167,18 +167,17 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
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),
),
],
),
),
],
);
},
);

View file

@ -143,28 +143,27 @@ class _MatchWidgetState extends State<MatchWidget> {
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<MatchItem> items, {required bool isLeft}) {
// Fixed sizes for consistent layout

View file

@ -203,42 +203,41 @@ class _MatrixWidgetState extends State<MatrixWidget>
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),
],
),
),
],
);
}

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: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<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);
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,
),
);
}

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

View file

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

View file

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