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

@ -456,6 +456,17 @@ class AdminCardsApiV2 {
);
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 packId = packs.isNotEmpty ? packs.first.id : null;
@ -501,7 +512,7 @@ class AdminCardsApiV2 {
final cardId = await _db.packDao.createCard(companion);
// Если передан packId, создать связь через CardPackCards
if (requestDto.packId != null) {
if (requestDto.packId != null && requestDto.packId!.isNotEmpty) {
await _db.packDao.addCardToPack(cardId: cardId, packId: requestDto.packId!);
}

View file

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

View file

@ -553,88 +553,92 @@ class TestManager {
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>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = questions.map((q) {
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'id': q.id,
'word': q.word,
};
final questionsList = await Future.wait(
questions.map((q) async {
// Build question JSON from separate fields
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'id': q.id,
'word': q.word,
};
// Parse options (JSON array of buttons)
List<dynamic> buttons = [];
try {
buttons = json.decode(q.options) as List<dynamic>;
} 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;
// Parse options (JSON array of buttons)
List<dynamic> buttons = [];
try {
buttons = json.decode(q.options) as List<dynamic>;
} catch (e) {
buttons = [];
}
return button;
}).toList();
questionJson['buttons'] = buttonsWithUrls;
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin
final updatedButtons = await Future.wait(
buttons.map((button) async {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString();
// Add answer
questionJson['answer'] = q.answer;
// Convert image to presigned URL
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
// 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) {
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) {
// If uiData is empty or invalid, ignore
}
return AbstractTestQuestion.fromJson(questionJson);
}).toList();
return AbstractTestQuestion.fromJson(questionJson);
}),
);
final normalizedCover =
await _normalizeImageValueForDb(test.cover, packId: packId);
final coverUrl = await _imageValueToApiUrl(
normalizedCover,
packId: packId,
);
testDtos.add(TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: test.cover,
cover: normalizedCover, // Object ID (for admin)
coverUrl: coverUrl, // Presigned URL (for display)
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,

View file

@ -295,6 +295,20 @@ class TestsStateManager extends StateManager<TestsState> {
if (currentState is! _GameSessionActive || _currentTest == null) return;
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!);
// Play completion sound

View file

@ -544,10 +544,15 @@ class _GamePageState extends State<GamePage> {
Future<void> _finishGame() async {
log('Finish button pressed', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
if (userScope != null) {
log('Completing game session...', name: 'GamePage');
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(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : colorScheme.onSurface,

View file

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

View file

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

View file

@ -59,17 +59,6 @@ class PackCardItem extends StatelessWidget {
child: Container(
key: ValueKey('$favoriteRootKeyPrefix${card.id}'),
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(
isFavorite ? Icons.favorite : Icons.favorite_border,
size: 14,