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,
|
||||
'subtitle': dto.subtitle,
|
||||
'color': dto.color,
|
||||
'cover': dto.imageBase64,
|
||||
'cover': dto.imageUrl,
|
||||
'cards': dto.cards,
|
||||
'enabled': pack.enabled,
|
||||
'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 card voices metadata
|
||||
/// Returns JSON list of VoiceDto
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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? 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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue