ads
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 22:11:07 +03:00
parent 0f49280805
commit dc01b02508
11 changed files with 368 additions and 502 deletions

View file

@ -712,6 +712,8 @@ class AdminTestsApiV2 {
uiData['template'] = questionJson['template'];
if (questionJson['matrixSize'] != null)
uiData['matrixSize'] = questionJson['matrixSize'];
if (questionJson['stages'] != null)
uiData['stages'] = questionJson['stages'];
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
@ -839,6 +841,8 @@ class AdminTestsApiV2 {
uiData['template'] = questionJson['template'];
if (questionJson['matrixSize'] != null)
uiData['matrixSize'] = questionJson['matrixSize'];
if (questionJson['stages'] != null)
uiData['stages'] = questionJson['stages'];
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(

View file

@ -17,7 +17,7 @@ class MinioConfig {
// Presigned URL expiration (7 days)
// Increased from 4 hours to prevent 403 errors on cached card data
static const int presignedUrlExpirySeconds = 7 * 24 * 60 * 60;
static const int presignedUrlExpirySeconds = 1 * 24 * 60 * 60;
MinioConfig({
required this.endpoint,

View file

@ -194,35 +194,20 @@ class MatrixQuestionGenerator implements QuestionGenerator {
// Generate stages for all cards
final stages = _generateStages(selected, questionType);
// Create cards based on question type
// Create cards - always include original and translation for back side display
// Front side will show only relevant fields based on question type,
// but back side needs both original and translation
final cards = selected.map((c) {
if (questionType.translationCards) {
// Always include original and translation for back side
// Include image if available (for image-based question types)
return MatrixCardDto(
id: c.id,
translation: c.translation,
// Don't include image or original for translation cards
);
} else if (questionType.originalCards) {
return MatrixCardDto(
id: c.id,
original: c.original,
// Don't include image or translation for original cards
);
} else if (questionType.imageCards) {
return MatrixCardDto(
id: c.id,
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
// Don't include text for image cards
);
} else {
// Fallback: include all fields
return MatrixCardDto(
id: c.id,
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
image: questionType.imageCards
? (_imageIdToUrl(c.image) ?? c.image ?? c.id)
: null,
original: c.original,
translation: c.translation,
);
}
}).toList();
// Get first stage for backward compatibility

View file

@ -780,6 +780,8 @@ class TestManager {
uiData['template'] = questionJson['template'];
if (questionJson['matrixSize'] != null)
uiData['matrixSize'] = questionJson['matrixSize'];
if (questionJson['stages'] != null)
uiData['stages'] = questionJson['stages'];
final questionCompanion = TestQuestionsCompanion.insert(
testId: testId,

View file

@ -277,28 +277,6 @@ class _GamePageState extends State<GamePage> {
final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h;
final spacingAfterAnswer = availableHeight > 700 ? 16.h : 12.h;
// Calculate max heights for question and answer sections
// Reserve space: progress (40-100px), navigation buttons (56px), spacing
final reservedHeight = progressMode == ProgressIndicatorMode.full
? 120.h
: progressMode == ProgressIndicatorMode.compact
? 80.h
: 60.h;
final navigationHeight =
(_canGoPrevious(state) ||
_canGoNext(state) ||
_isLastQuestion(state))
? 72.h
: 0;
final totalSpacing =
spacingAfterProgress + spacingAfterQuestion + spacingAfterAnswer;
final availableForContent =
availableHeight - reservedHeight - navigationHeight - totalSpacing;
// Distribute: 40% question, 60% answers (adjustable)
final questionMaxHeight = availableForContent * 0.4;
final answerMaxHeight = availableForContent * 0.6;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: contentWidth),
@ -333,10 +311,6 @@ class _GamePageState extends State<GamePage> {
// Question display
Flexible(
flex: 2,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: questionMaxHeight,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: KeyedSubtree(
@ -364,15 +338,12 @@ class _GamePageState extends State<GamePage> {
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(
question: currentQuestion.question,
maxHeight: questionMaxHeight,
)
: QuestionDisplay(
question: currentQuestion,
onPlayAudio:
widget.questionAudioPlayback ??
_playQuestionAudio,
maxHeight: questionMaxHeight,
),
),
),
),
@ -385,10 +356,6 @@ class _GamePageState extends State<GamePage> {
// Answer input based on question type with smooth transitions
Flexible(
flex: 3,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: answerMaxHeight,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeInOut,
@ -420,22 +387,18 @@ class _GamePageState extends State<GamePage> {
onAnswerSelected: _onAnswerSelected,
isAnswerSubmitted: isAnswerSubmitted,
isCorrect: isCorrect,
maxHeight: answerMaxHeight,
),
inputLetters: (q) => InputLettersWidget(
question: q,
maxHeight: answerMaxHeight,
),
match: (q) => MatchWidget(
question: q,
maxHeight: answerMaxHeight,
),
matrix: (q) => const SizedBox.shrink(),
),
),
),
),
),
// Navigation buttons (show when navigation is possible)
if (_canGoPrevious(state) ||

View file

@ -11,7 +11,6 @@ class AnswerOptions extends StatelessWidget {
required this.isAnswerSubmitted,
required this.isCorrect,
this.enabled = true,
this.maxHeight,
super.key,
});
@ -21,7 +20,6 @@ class AnswerOptions extends StatelessWidget {
final bool isAnswerSubmitted;
final bool isCorrect;
final bool enabled;
final double? maxHeight;
void _onAnswerSelected(BuildContext context, String option) {
// Note: Sound service access would be implemented through proper DI injection
@ -48,22 +46,11 @@ class AnswerOptions extends StatelessWidget {
hasOptionItems &&
question.optionItems.any((item) => item.image != null);
// Adapt aspect ratio based on maxHeight
double childAspectRatio;
if (hasImages) {
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 1.0 : 1.1;
} else {
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 3.0 : 4.0;
}
// Fixed aspect ratios for consistent layout
final childAspectRatio = hasImages ? 1.1 : 4.0;
final spacing = 12.0;
// Adapt spacing based on available height
final spacing = maxHeight != null && maxHeight! < 300 ? 8.0 : 12.0;
return ConstrainedBox(
constraints: maxHeight != null
? BoxConstraints(maxHeight: maxHeight!)
: const BoxConstraints(),
child: GridView.builder(
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@ -82,7 +69,6 @@ class AnswerOptions extends StatelessWidget {
return _buildAnswerOption(context, option);
}
},
),
);
},
);
@ -186,7 +172,7 @@ class AnswerOptions extends StatelessWidget {
splashColor: borderColor?.withOpacity(0.1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border.all(
color:
@ -268,6 +254,7 @@ class AnswerOptions extends StatelessWidget {
textColor: textColor,
isSelected: isSelected,
isCorrectOption: isCorrectOption,
isAnswerSubmitted: isAnswerSubmitted,
),
),
],
@ -288,6 +275,7 @@ class AnswerOptions extends StatelessWidget {
Color? textColor,
required bool isSelected,
required bool isCorrectOption,
required bool isAnswerSubmitted,
}) {
// If we have an image, show it (with optional text below)
if (image != null) {
@ -345,7 +333,7 @@ class AnswerOptions extends StatelessWidget {
duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: textColor,
fontWeight: isSelected || isCorrectOption
fontWeight: isSelected || (isAnswerSubmitted && isCorrectOption)
? FontWeight.w600
: FontWeight.normal,
fontSize: isSelected ? 17 : 16,

View file

@ -7,10 +7,9 @@ import '../../../domain/models/game_question.dart';
/// Widget for input letters questions - user types letters to fill in blanks
class InputLettersWidget extends StatefulWidget {
const InputLettersWidget({required this.question, this.maxHeight, super.key});
const InputLettersWidget({required this.question, super.key});
final InputLettersQuestion question;
final double? maxHeight;
@override
State<InputLettersWidget> createState() => _InputLettersWidgetState();
@ -72,22 +71,16 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
@override
Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final containerPadding = isCompact ? 12.w : 16.w;
final containerMargin = isCompact ? 12.h : 16.h;
final spacingAfterTitle = isCompact ? 8.h : 12.h;
final spacingAfterWord = isCompact ? 8.h : 12.h;
final spacingAfterGrid = isCompact ? 12.h : 16.h;
final showFillInstruction = !isCompact; // Hide on very compact screens
// Fixed spacing and padding for consistent layout
final containerPadding = 16.w;
final containerMargin = 16.h;
final spacingAfterTitle = 12.h;
final spacingAfterWord = 12.h;
final spacingAfterGrid = 16.h;
return LayoutBuilder(
builder: (context, constraints) {
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
@ -110,15 +103,13 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showFillInstruction)
Text(
'Fill in the blanks:',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
if (showFillInstruction &&
widget.question.word.isNotEmpty)
if (widget.question.word.isNotEmpty)
SizedBox(height: spacingAfterTitle),
if (widget.question.word.isNotEmpty) ...[
Text(
@ -208,12 +199,11 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final parts = <Widget>[];
int inputIndex = 0;
// Adapt sizes based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final cellWidth = isCompact ? 24.w : 28.w;
final cellHeight = isCompact ? 36.h : 48.h;
final cellFontSize = isCompact ? 16.sp : 20.sp;
final blankCellWidth = isCompact ? 28.w : 32.w;
// Fixed cell sizes for consistent layout
final cellWidth = 28.w;
final cellHeight = 48.h;
final cellFontSize = 20.sp;
final blankCellWidth = 32.w;
for (int i = 0; i < template.length; i++) {
final char = template[i];
@ -283,11 +273,8 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
Widget _buildButtonsGrid(BoxConstraints constraints) {
final hasImages = widget.question.buttons.any((b) => b.image != null);
final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3;
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final childAspectRatio = hasImages
? (isCompact ? 1.0 : 1.2)
: (isCompact ? 2.0 : 2.0);
final spacing = isCompact ? 8.0 : 12.0;
final childAspectRatio = hasImages ? 1.2 : 2.0;
final spacing = 12.0;
return GridView.builder(
shrinkWrap: true,

View file

@ -7,10 +7,9 @@ import '../../../domain/models/game_question.dart';
/// Widget for match questions - user connects items from two columns
class MatchWidget extends StatefulWidget {
const MatchWidget({required this.question, this.maxHeight, super.key});
const MatchWidget({required this.question, super.key});
final MatchQuestion question;
final double? maxHeight;
@override
State<MatchWidget> createState() => _MatchWidgetState();
@ -23,26 +22,18 @@ class _MatchWidgetState extends State<MatchWidget> {
@override
Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
final instructionPadding = isCompact ? 12.w : 16.w;
final instructionMargin = isCompact ? 12.h : 16.h;
final connectionPadding = isCompact ? 12.w : 16.w;
final connectionMargin = isCompact ? 12.h : 16.h;
final spacingAfterColumns = isCompact ? 12.h : 16.h;
final instructionText = isCompact
? 'Connect matching items'
: 'Connect the matching items by tapping them in order';
// Fixed spacing and padding for consistent layout
final instructionPadding = 16.w;
final instructionMargin = 16.h;
final connectionPadding = 16.w;
final connectionMargin = 16.h;
final spacingAfterColumns = 16.h;
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600;
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Instructions
@ -61,7 +52,7 @@ class _MatchWidgetState extends State<MatchWidget> {
],
),
child: Text(
instructionText,
'Connect the matching items by tapping them in order',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
@ -87,11 +78,9 @@ class _MatchWidgetState extends State<MatchWidget> {
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (!isCompact) ...[
SizedBox(height: 8.h),
..._buildConnectionDisplay(),
],
],
),
),
],
@ -108,7 +97,7 @@ class _MatchWidgetState extends State<MatchWidget> {
isLeft: true,
),
),
SizedBox(width: isCompact ? 16.w : 24.w),
SizedBox(width: 24.w),
Expanded(
child: _buildColumn(
widget.question.rightItems,
@ -128,14 +117,14 @@ class _MatchWidgetState extends State<MatchWidget> {
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
SizedBox(height: 12.h),
Flexible(
child: _buildColumn(
widget.question.leftItems,
isLeft: true,
),
),
SizedBox(height: isCompact ? 16.h : 24.h),
SizedBox(height: 24.h),
Text(
'Right Column',
style: Theme.of(context).textTheme.titleMedium
@ -147,7 +136,7 @@ class _MatchWidgetState extends State<MatchWidget> {
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
SizedBox(height: 12.h),
Flexible(
child: _buildColumn(
widget.question.rightItems,
@ -178,18 +167,16 @@ class _MatchWidgetState extends State<MatchWidget> {
}
Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
final itemMargin = isCompact ? 4.h : 6.h;
final itemPadding = isCompact ? 8.w : 12.w;
final imageSize = isCompact ? 32.0 : 40.0;
final fontSize = isCompact ? 14.sp : 16.sp;
final iconSize = isCompact ? 18.sp : 20.sp;
// Fixed sizes for consistent layout
final itemMargin = 6.h;
final itemPadding = 12.w;
final imageSize = 40.0;
final fontSize = 16.sp;
final iconSize = 20.sp;
return ListView.builder(
itemCount: items.length,
itemExtent: isCompact
? 52.0
: 64.0, // Fixed item height for better performance
itemExtent: 64.0, // Fixed item height for better performance
itemBuilder: (context, index) {
final item = items[index];
final isSelected = isLeft
@ -240,7 +227,7 @@ class _MatchWidgetState extends State<MatchWidget> {
),
),
),
SizedBox(width: isCompact ? 8.w : 12.w),
SizedBox(width: 12.w),
],
Expanded(
child: Text(

View file

@ -21,14 +21,12 @@ class MatrixWidget extends StatefulWidget {
required this.question,
this.onWrongAttempt,
this.onCompleted,
this.maxHeight,
super.key,
});
final MatrixQuestion question;
final Future<void> Function()? onWrongAttempt;
final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted;
final double? maxHeight;
@override
State<MatrixWidget> createState() => _MatrixWidgetState();
@ -175,44 +173,17 @@ class _MatrixWidgetState extends State<MatrixWidget>
...List<MatrixCard?>.filled(total - _slots.length, null),
];
// Calculate available height for grid (reserve space for target container)
// Target container: ~100-120px (compact) to 140-160px (full)
final targetContainerHeight =
widget.maxHeight != null && widget.maxHeight! < 350 ? 100.0 : 140.0;
final spacingBetween = widget.maxHeight != null && widget.maxHeight! < 350
? 12.h
: 16.h;
// Fixed spacing values for consistent layout
final spacingBetween = 16.h;
final cellSpacing = 10.0;
final targetPaddingH = 20.w;
final targetPaddingV = 16.h;
final gridMaxHeight = widget.maxHeight != null
? widget.maxHeight! - targetContainerHeight - spacingBetween
: null;
// Adapt spacing for grid cells
final cellSpacing = widget.maxHeight != null && widget.maxHeight! < 350
? 6.0
: 10.0;
// Adapt padding for target container
final targetPaddingH = widget.maxHeight != null && widget.maxHeight! < 350
? 16.w
: 20.w;
final targetPaddingV = widget.maxHeight != null && widget.maxHeight! < 350
? 12.h
: 16.h;
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ConstrainedBox(
constraints: gridMaxHeight != null
? BoxConstraints(maxHeight: gridMaxHeight)
: const BoxConstraints(),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -229,7 +200,6 @@ class _MatrixWidgetState extends State<MatrixWidget>
},
),
),
),
SizedBox(height: spacingBetween),
Container(
padding: EdgeInsets.symmetric(

View file

@ -10,13 +10,11 @@ class QuestionDisplay extends StatelessWidget {
const QuestionDisplay({
required this.question,
this.onPlayAudio,
this.maxHeight,
super.key,
});
final GameQuestion question;
final QuestionAudioPlayback? onPlayAudio;
final double? maxHeight;
@override
Widget build(BuildContext context) {
@ -78,43 +76,17 @@ class QuestionDisplay extends StatelessWidget {
}) {
final theme = Theme.of(context).textTheme;
// Calculate available height for image (30-40% of maxHeight, min 120, max 250)
double? imageMaxHeight;
if (maxHeight != null) {
final imageHeight = maxHeight! * 0.35;
imageMaxHeight = imageHeight.clamp(120.0, 250.0);
} else {
imageMaxHeight = 200.h;
}
// Fixed spacing values for consistent layout
final spacingAfterImage = 12.h;
final spacingAfterText = 8.h;
// Calculate spacing based on available height
final spacingAfterImage = maxHeight != null && maxHeight! < 300
? 8.h
: 12.h;
final spacingAfterText = maxHeight != null && maxHeight! < 300 ? 6.h : 8.h;
// Determine max lines for text based on available height
final maxLines = maxHeight != null && maxHeight! < 250 ? 3 : 5;
// Adjust font size slightly on very small screens
final fontSize = maxHeight != null && maxHeight! < 200 ? 18.sp : 20.sp;
return ConstrainedBox(
constraints: maxHeight != null
? BoxConstraints(maxHeight: maxHeight!)
: const BoxConstraints(),
child: Column(
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Image display
if (image != null) ...[
Flexible(
child: Container(
constraints: BoxConstraints(
maxHeight: imageMaxHeight,
maxWidth: double.infinity,
),
child: Image.network(
image,
fit: BoxFit.contain,
@ -137,7 +109,6 @@ class QuestionDisplay extends StatelessWidget {
},
),
),
),
SizedBox(height: spacingAfterImage),
],
@ -147,12 +118,12 @@ class QuestionDisplay extends StatelessWidget {
child: Text(
text,
style: theme.headlineSmall?.copyWith(
fontSize: fontSize,
fontSize: 20.sp,
height: 1.4,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: maxLines,
maxLines: 5,
overflow: TextOverflow.fade,
),
),
@ -164,7 +135,6 @@ class QuestionDisplay extends StatelessWidget {
_QuestionAudioButton(audioUrl: audio, onPlayAudio: onPlayAudio),
],
],
),
);
}
}

View file

@ -37,8 +37,18 @@
<!-- Telegram Web App SDK -->
<script src="https://telegram.org/js/telegram-web-app.js" defer></script>
<!-- Adsgram SDK for rewarded ads - must load before foos.js -->
<!-- Adsgram SDK for rewarded ads - must load before foos.js
<script src="https://sad.adsgram.ai/js/sad.min.js"></script>
-->
<script src="https://richinfo.co/richpartners/telegram/js/tg-ob.js"></script>
<script>
window.TelegramAdsController = new TelegramAdsController();
window.TelegramAdsController.initialize({
pubId: "996812",
appId: "5105",
});
</script>
<!-- JavaScript bridge for Adsgram SDK integration -->
<script src="foos.js"></script>