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
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:
parent
dc01b02508
commit
1bfec9d511
16 changed files with 268 additions and 365 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,6 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,6 @@ class _MatchWidgetState extends State<MatchWidget> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,6 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue