fixes
Some checks are pending
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

This commit is contained in:
Dmitry 2025-12-20 02:37:34 +03:00
parent de06c74b72
commit 9c2456f685
9 changed files with 133 additions and 94 deletions

View file

@ -455,7 +455,18 @@ class AdminCardsApiV2 {
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),
); );
await _db.packDao.updateCard(updated); await _db.packDao.updateCard(updated);
// Обновить связь с паком
if (requestDto.packId != null && requestDto.packId!.isNotEmpty) {
// Сначала удалить старую связь
await _db.packDao.removeCardFromPack(updated.id, existing.packId ?? '');
// Затем добавить новую
await _db.packDao.addCardToPack(cardId: updated.id, packId: requestDto.packId!);
} else {
// Если packId не указан или пустой, удалить связь
await _db.packDao.removeCardFromPack(updated.id, existing.packId ?? '');
}
// Получить паки для карточки // Получить паки для карточки
final packs = await _db.packDao.getPacksForCard(updated.id); final packs = await _db.packDao.getPacksForCard(updated.id);
final packId = packs.isNotEmpty ? packs.first.id : null; final packId = packs.isNotEmpty ? packs.first.id : null;
@ -501,7 +512,7 @@ class AdminCardsApiV2 {
final cardId = await _db.packDao.createCard(companion); final cardId = await _db.packDao.createCard(companion);
// Если передан packId, создать связь через CardPackCards // Если передан packId, создать связь через CardPackCards
if (requestDto.packId != null) { if (requestDto.packId != null && requestDto.packId!.isNotEmpty) {
await _db.packDao.addCardToPack(cardId: cardId, packId: requestDto.packId!); await _db.packDao.addCardToPack(cardId: cardId, packId: requestDto.packId!);
} }

View file

@ -56,7 +56,7 @@ class AdminTestsApiV2 {
final cardId = await _db.packDao.createCard(companion); final cardId = await _db.packDao.createCard(companion);
// If packId is provided, link card to pack // If packId is provided, link card to pack
if (packId != null) { if (packId != null && packId.isNotEmpty) {
await _db.packDao.addCardToPack(cardId: cardId, packId: packId); await _db.packDao.addCardToPack(cardId: cardId, packId: packId);
} }

View file

@ -553,88 +553,92 @@ class TestManager {
final tests = await _db.testDao.getTestsByPackId(packId); final tests = await _db.testDao.getTestsByPackId(packId);
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
return imageValue;
}
// If it looks like a UUID (card ID), convert to URL
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Otherwise, assume it's already a card ID and convert
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
final testDtos = <TestDto>[]; final testDtos = <TestDto>[];
for (final test in tests) { for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id); final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id); final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = questions.map((q) { final questionsList = await Future.wait(
// Build question JSON from separate fields questions.map((q) async {
final questionJson = <String, dynamic>{ // Build question JSON from separate fields
'questionType': q.questionType, final questionJson = <String, dynamic>{
'id': q.id, 'questionType': q.questionType,
'word': q.word, 'id': q.id,
}; 'word': q.word,
};
// Parse options (JSON array of buttons)
List<dynamic> buttons = []; // Parse options (JSON array of buttons)
try { List<dynamic> buttons = [];
buttons = json.decode(q.options) as List<dynamic>; try {
} catch (e) { buttons = json.decode(q.options) as List<dynamic>;
buttons = []; } catch (e) {
} buttons = [];
// Convert button images to URLs
final buttonsWithUrls = buttons.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
);
return buttonMap;
} }
return button;
}).toList(); // Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin
questionJson['buttons'] = buttonsWithUrls; final updatedButtons = await Future.wait(
buttons.map((button) async {
// Add answer if (button is Map<String, dynamic> && button['image'] != null) {
questionJson['answer'] = q.answer; final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString();
// Parse uiData (image, text, audio, template)
try { // Convert image to presigned URL
final uiData = json.decode(q.uiData) as Map<String, dynamic>; final imageUrl = await _imageValueToApiUrl(
// Convert question image to URL imageValue,
if (uiData['image'] != null) { packId: packId,
uiData['image'] = _convertImageToUrl( );
uiData['image'] as String?,
packId, if (imageUrl != null) {
); buttonMap['imageUrl'] = imageUrl;
}
return buttonMap;
}
return button;
}),
);
questionJson['buttons'] = updatedButtons;
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
// Convert question image to URL
if (uiData['image'] != null) {
final imageValue = uiData['image']?.toString();
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
uiData['imageUrl'] = imageUrl;
}
}
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
} }
questionJson.addAll(uiData);
} catch (e) { return AbstractTestQuestion.fromJson(questionJson);
// If uiData is empty or invalid, ignore }),
} );
return AbstractTestQuestion.fromJson(questionJson); final normalizedCover =
}).toList(); await _normalizeImageValueForDb(test.cover, packId: packId);
final coverUrl = await _imageValueToApiUrl(
normalizedCover,
packId: packId,
);
testDtos.add(TestDto( testDtos.add(TestDto(
id: test.id.toString(), id: test.id.toString(),
name: test.name, name: test.name,
color: test.color, color: test.color,
cover: test.cover, cover: normalizedCover, // Object ID (for admin)
coverUrl: coverUrl, // Presigned URL (for display)
version: test.version ?? '1.0', version: test.version ?? '1.0',
time: test.time, time: test.time,
timeSubtitle: test.timeSubtitle, timeSubtitle: test.timeSubtitle,

View file

@ -295,6 +295,20 @@ class TestsStateManager extends StateManager<TestsState> {
if (currentState is! _GameSessionActive || _currentTest == null) return; if (currentState is! _GameSessionActive || _currentTest == null) return;
try { try {
// Check if current question has been answered
final currentQuestion = currentState.questions[currentState.currentQuestionIndex];
final questionId = _getQuestionId(currentQuestion);
final existingResult = _gameSessionManager.getQuestionResult(questionId);
// If current question hasn't been answered, save it as unanswered
if (existingResult == null || existingResult.answeredAt == null) {
log(
'Current question not answered, saving as unanswered before completion',
name: 'TestsStateManager',
);
_gameSessionManager.submitAnswer(questionId, currentQuestion, '');
}
final result = _gameSessionManager.completeSession(_currentTest!.id!); final result = _gameSessionManager.completeSession(_currentTest!.id!);
// Play completion sound // Play completion sound

View file

@ -544,10 +544,15 @@ class _GamePageState extends State<GamePage> {
Future<void> _finishGame() async { Future<void> _finishGame() async {
log('Finish button pressed', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false); final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope; final userScope = appScope?.userScopeHolder.scope;
if (userScope != null) { if (userScope != null) {
log('Completing game session...', name: 'GamePage');
await userScope.testsModule.testsStateManager.completeGameSession(); await userScope.testsModule.testsStateManager.completeGameSession();
log('Game session completion called', name: 'GamePage');
} else {
log('No user scope available when finishing game', name: 'GamePage');
} }
} }

View file

@ -48,10 +48,6 @@ class CardFavoriteButton extends StatelessWidget {
), ),
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
),
child: Icon( child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border, isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : colorScheme.onSurface, color: isFavorite ? Colors.red : colorScheme.onSurface,

View file

@ -432,6 +432,7 @@ class _CardSide extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Верхняя секция: Original + Translation + Voice Controls // Верхняя секция: Original + Translation + Voice Controls
Container( Container(

View file

@ -102,6 +102,17 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (widget.question.word.isNotEmpty) ...[
SizedBox(height: 16.h),
Text(
widget.question.word,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildTemplateDisplay(), _buildTemplateDisplay(),
], ],
@ -166,10 +177,9 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final template = widget.question.template; final template = widget.question.template;
final currentInput = _currentAnswer; final currentInput = _currentAnswer;
return Wrap( return Row(
alignment: WrapAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
spacing: 8.w, mainAxisSize: MainAxisSize.min,
runSpacing: 8.h,
children: _buildTemplateParts(template, currentInput), children: _buildTemplateParts(template, currentInput),
); );
} }
@ -188,8 +198,9 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
parts.add( parts.add(
Container( Container(
width: 40.w, width: 32.w,
height: 48.h, height: 48.h,
margin: EdgeInsets.symmetric(horizontal: 2.w),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
@ -207,16 +218,23 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
fontSize: 20.sp, fontSize: 20.sp,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
fontFeatures: const [FontFeature.tabularFigures()],
), ),
), ),
), ),
); );
} else if (char == ' ') {
// Space character - add small spacing
parts.add(
SizedBox(width: 8.w),
);
} else { } else {
// This is a regular character // This is a regular character
parts.add( parts.add(
Container( Container(
width: 32.w, width: 28.w,
height: 48.h, height: 48.h,
margin: EdgeInsets.symmetric(horizontal: 2.w),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
char, char,
@ -224,6 +242,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
fontSize: 20.sp, fontSize: 20.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
), ),
), ),
), ),

View file

@ -59,17 +59,6 @@ class PackCardItem extends StatelessWidget {
child: Container( child: Container(
key: ValueKey('$favoriteRootKeyPrefix${card.id}'), key: ValueKey('$favoriteRootKeyPrefix${card.id}'),
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon( child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border, isFavorite ? Icons.favorite : Icons.favorite_border,
size: 14, size: 14,