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

View file

@ -17,7 +17,7 @@ class MinioConfig {
// Presigned URL expiration (7 days) // Presigned URL expiration (7 days)
// Increased from 4 hours to prevent 403 errors on cached card data // 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({ MinioConfig({
required this.endpoint, required this.endpoint,

View file

@ -194,35 +194,20 @@ class MatrixQuestionGenerator implements QuestionGenerator {
// Generate stages for all cards // Generate stages for all cards
final stages = _generateStages(selected, questionType); 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) { final cards = selected.map((c) {
if (questionType.translationCards) { // Always include original and translation for back side
return MatrixCardDto( // Include image if available (for image-based question types)
id: c.id, return MatrixCardDto(
translation: c.translation, id: c.id,
// Don't include image or original for translation cards image: questionType.imageCards
); ? (_imageIdToUrl(c.image) ?? c.image ?? c.id)
} else if (questionType.originalCards) { : null,
return MatrixCardDto( original: c.original,
id: c.id, translation: c.translation,
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,
original: c.original,
translation: c.translation,
);
}
}).toList(); }).toList();
// Get first stage for backward compatibility // Get first stage for backward compatibility

View file

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

View file

@ -277,28 +277,6 @@ class _GamePageState extends State<GamePage> {
final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h; final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h;
final spacingAfterAnswer = 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( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: contentWidth), constraints: BoxConstraints(maxWidth: contentWidth),
@ -333,47 +311,40 @@ class _GamePageState extends State<GamePage> {
// Question display // Question display
Flexible( Flexible(
flex: 2, flex: 2,
child: ConstrainedBox( child: AnimatedSwitcher(
constraints: BoxConstraints( duration: const Duration(milliseconds: 300),
maxHeight: questionMaxHeight, child: KeyedSubtree(
), key: questionKey,
child: AnimatedSwitcher( child: Material(
duration: const Duration(milliseconds: 300), key: GamePage.questionCardKey,
child: KeyedSubtree( color: colorScheme.surface,
key: questionKey, surfaceTintColor: colorScheme.surfaceTint,
child: Material( elevation: 3,
key: GamePage.questionCardKey, shadowColor: theme.shadowColor.withOpacity(
color: colorScheme.surface, theme.brightness == Brightness.dark
surfaceTintColor: colorScheme.surfaceTint, ? 0.35
elevation: 3, : 0.14,
shadowColor: theme.shadowColor.withOpacity( ),
theme.brightness == Brightness.dark shape: RoundedRectangleBorder(
? 0.35 borderRadius: BorderRadius.circular(18.r),
: 0.14, side: BorderSide(
color: colorScheme.outlineVariant,
), ),
shape: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(18.r), child: Padding(
side: BorderSide( padding: EdgeInsets.all(
color: colorScheme.outlineVariant, isNarrow ? 14.w : 18.w,
),
),
child: Padding(
padding: EdgeInsets.all(
isNarrow ? 14.w : 18.w,
),
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(
question: currentQuestion.question,
maxHeight: questionMaxHeight,
)
: QuestionDisplay(
question: currentQuestion,
onPlayAudio:
widget.questionAudioPlayback ??
_playQuestionAudio,
maxHeight: questionMaxHeight,
),
), ),
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(
question: currentQuestion.question,
)
: QuestionDisplay(
question: currentQuestion,
onPlayAudio:
widget.questionAudioPlayback ??
_playQuestionAudio,
),
), ),
), ),
), ),
@ -385,53 +356,45 @@ class _GamePageState extends State<GamePage> {
// Answer input based on question type with smooth transitions // Answer input based on question type with smooth transitions
Flexible( Flexible(
flex: 3, flex: 3,
child: ConstrainedBox( child: AnimatedSwitcher(
constraints: BoxConstraints( duration: const Duration(milliseconds: 400),
maxHeight: answerMaxHeight, switchInCurve: Curves.easeInOut,
), switchOutCurve: Curves.easeInOut,
child: AnimatedSwitcher( transitionBuilder: (child, animation) {
duration: const Duration(milliseconds: 400), return FadeTransition(
switchInCurve: Curves.easeInOut, opacity: animation,
switchOutCurve: Curves.easeInOut, child: SlideTransition(
transitionBuilder: (child, animation) { position: Tween<Offset>(
return FadeTransition( begin: const Offset(0.05, 0),
opacity: animation, end: Offset.zero,
child: SlideTransition( ).animate(animation),
position: Tween<Offset>( child: child,
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Container(
key: ValueKey(
'question_input_${currentQuestion.hashCode}',
), ),
child: currentQuestion.when( );
multipleChoice: (q) => AnswerOptions( },
question: q, child: Container(
selectedAnswer: key: ValueKey(
_getSelectedAnswerForMultipleChoice( 'question_input_${currentQuestion.hashCode}',
q, ),
questionResults, child: currentQuestion.when(
), multipleChoice: (q) => AnswerOptions(
onAnswerSelected: _onAnswerSelected, question: q,
isAnswerSubmitted: isAnswerSubmitted, selectedAnswer:
isCorrect: isCorrect, _getSelectedAnswerForMultipleChoice(
maxHeight: answerMaxHeight, q,
), questionResults,
inputLetters: (q) => InputLettersWidget( ),
question: q, onAnswerSelected: _onAnswerSelected,
maxHeight: answerMaxHeight, isAnswerSubmitted: isAnswerSubmitted,
), isCorrect: isCorrect,
match: (q) => MatchWidget(
question: q,
maxHeight: answerMaxHeight,
),
matrix: (q) => const SizedBox.shrink(),
), ),
inputLetters: (q) => InputLettersWidget(
question: q,
),
match: (q) => MatchWidget(
question: q,
),
matrix: (q) => const SizedBox.shrink(),
), ),
), ),
), ),

View file

@ -11,7 +11,6 @@ class AnswerOptions extends StatelessWidget {
required this.isAnswerSubmitted, required this.isAnswerSubmitted,
required this.isCorrect, required this.isCorrect,
this.enabled = true, this.enabled = true,
this.maxHeight,
super.key, super.key,
}); });
@ -21,7 +20,6 @@ class AnswerOptions extends StatelessWidget {
final bool isAnswerSubmitted; final bool isAnswerSubmitted;
final bool isCorrect; final bool isCorrect;
final bool enabled; final bool enabled;
final double? maxHeight;
void _onAnswerSelected(BuildContext context, String option) { void _onAnswerSelected(BuildContext context, String option) {
// Note: Sound service access would be implemented through proper DI injection // Note: Sound service access would be implemented through proper DI injection
@ -48,22 +46,11 @@ class AnswerOptions extends StatelessWidget {
hasOptionItems && hasOptionItems &&
question.optionItems.any((item) => item.image != null); question.optionItems.any((item) => item.image != null);
// Adapt aspect ratio based on maxHeight // Fixed aspect ratios for consistent layout
double childAspectRatio; final childAspectRatio = hasImages ? 1.1 : 4.0;
if (hasImages) { final spacing = 12.0;
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 1.0 : 1.1;
} else {
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 3.0 : 4.0;
}
// Adapt spacing based on available height return GridView.builder(
final spacing = maxHeight != null && maxHeight! < 300 ? 8.0 : 12.0;
return ConstrainedBox(
constraints: maxHeight != null
? BoxConstraints(maxHeight: maxHeight!)
: const BoxConstraints(),
child: GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@ -82,8 +69,7 @@ class AnswerOptions extends StatelessWidget {
return _buildAnswerOption(context, option); return _buildAnswerOption(context, option);
} }
}, },
), );
);
}, },
); );
} }
@ -186,7 +172,7 @@ class AnswerOptions extends StatelessWidget {
splashColor: borderColor?.withOpacity(0.1), splashColor: borderColor?.withOpacity(0.1),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: color:
@ -268,6 +254,7 @@ class AnswerOptions extends StatelessWidget {
textColor: textColor, textColor: textColor,
isSelected: isSelected, isSelected: isSelected,
isCorrectOption: isCorrectOption, isCorrectOption: isCorrectOption,
isAnswerSubmitted: isAnswerSubmitted,
), ),
), ),
], ],
@ -288,6 +275,7 @@ class AnswerOptions extends StatelessWidget {
Color? textColor, Color? textColor,
required bool isSelected, required bool isSelected,
required bool isCorrectOption, required bool isCorrectOption,
required bool isAnswerSubmitted,
}) { }) {
// If we have an image, show it (with optional text below) // If we have an image, show it (with optional text below)
if (image != null) { if (image != null) {
@ -345,7 +333,7 @@ class AnswerOptions extends StatelessWidget {
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodyLarge!.copyWith( style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: textColor, color: textColor,
fontWeight: isSelected || isCorrectOption fontWeight: isSelected || (isAnswerSubmitted && isCorrectOption)
? FontWeight.w600 ? FontWeight.w600
: FontWeight.normal, : FontWeight.normal,
fontSize: isSelected ? 17 : 16, 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 /// Widget for input letters questions - user types letters to fill in blanks
class InputLettersWidget extends StatefulWidget { class InputLettersWidget extends StatefulWidget {
const InputLettersWidget({required this.question, this.maxHeight, super.key}); const InputLettersWidget({required this.question, super.key});
final InputLettersQuestion question; final InputLettersQuestion question;
final double? maxHeight;
@override @override
State<InputLettersWidget> createState() => _InputLettersWidgetState(); State<InputLettersWidget> createState() => _InputLettersWidgetState();
@ -72,71 +71,63 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight // Fixed spacing and padding for consistent layout
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; final containerPadding = 16.w;
final containerPadding = isCompact ? 12.w : 16.w; final containerMargin = 16.h;
final containerMargin = isCompact ? 12.h : 16.h; final spacingAfterTitle = 12.h;
final spacingAfterTitle = isCompact ? 8.h : 12.h; final spacingAfterWord = 12.h;
final spacingAfterWord = isCompact ? 8.h : 12.h; final spacingAfterGrid = 16.h;
final spacingAfterGrid = isCompact ? 12.h : 16.h;
final showFillInstruction = !isCompact; // Hide on very compact screens
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return ConstrainedBox( return Column(
constraints: widget.maxHeight != null mainAxisAlignment: MainAxisAlignment.center,
? BoxConstraints(maxHeight: widget.maxHeight!) mainAxisSize: MainAxisSize.min,
: const BoxConstraints(), children: [
child: Column( // Display the template with current input
mainAxisAlignment: MainAxisAlignment.center, Flexible(
mainAxisSize: MainAxisSize.min, child: Container(
children: [ padding: EdgeInsets.all(containerPadding),
// Display the template with current input margin: EdgeInsets.only(bottom: containerMargin),
Flexible( decoration: BoxDecoration(
child: Container( color: Theme.of(context).colorScheme.surface,
padding: EdgeInsets.all(containerPadding), borderRadius: BorderRadius.circular(16.r),
margin: EdgeInsets.only(bottom: containerMargin), boxShadow: [
decoration: BoxDecoration( BoxShadow(
color: Theme.of(context).colorScheme.surface, color: Colors.black.withOpacity(0.1),
borderRadius: BorderRadius.circular(16.r), blurRadius: 8,
boxShadow: [ offset: const Offset(0, 2),
BoxShadow( ),
color: Colors.black.withOpacity(0.1), ],
blurRadius: 8, ),
offset: const Offset(0, 2), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Fill in the blanks:',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
if (widget.question.word.isNotEmpty)
SizedBox(height: spacingAfterTitle),
if (widget.question.word.isNotEmpty) ...[
Text(
widget.question.word,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
), ),
], ],
), SizedBox(height: spacingAfterWord),
child: Column( _buildTemplateDisplay(),
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)
SizedBox(height: spacingAfterTitle),
if (widget.question.word.isNotEmpty) ...[
Text(
widget.question.word,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: spacingAfterWord),
_buildTemplateDisplay(),
],
),
), ),
), ),
),
// Buttons with images or text (if available) // Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[ if (widget.question.buttons.isNotEmpty) ...[
@ -208,12 +199,11 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final parts = <Widget>[]; final parts = <Widget>[];
int inputIndex = 0; int inputIndex = 0;
// Adapt sizes based on maxHeight // Fixed cell sizes for consistent layout
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; final cellWidth = 28.w;
final cellWidth = isCompact ? 24.w : 28.w; final cellHeight = 48.h;
final cellHeight = isCompact ? 36.h : 48.h; final cellFontSize = 20.sp;
final cellFontSize = isCompact ? 16.sp : 20.sp; final blankCellWidth = 32.w;
final blankCellWidth = isCompact ? 28.w : 32.w;
for (int i = 0; i < template.length; i++) { for (int i = 0; i < template.length; i++) {
final char = template[i]; final char = template[i];
@ -283,11 +273,8 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
Widget _buildButtonsGrid(BoxConstraints constraints) { Widget _buildButtonsGrid(BoxConstraints constraints) {
final hasImages = widget.question.buttons.any((b) => b.image != null); final hasImages = widget.question.buttons.any((b) => b.image != null);
final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3; final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3;
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; final childAspectRatio = hasImages ? 1.2 : 2.0;
final childAspectRatio = hasImages final spacing = 12.0;
? (isCompact ? 1.0 : 1.2)
: (isCompact ? 2.0 : 2.0);
final spacing = isCompact ? 8.0 : 12.0;
return GridView.builder( return GridView.builder(
shrinkWrap: true, shrinkWrap: true,

View file

@ -7,10 +7,9 @@ import '../../../domain/models/game_question.dart';
/// Widget for match questions - user connects items from two columns /// Widget for match questions - user connects items from two columns
class MatchWidget extends StatefulWidget { class MatchWidget extends StatefulWidget {
const MatchWidget({required this.question, this.maxHeight, super.key}); const MatchWidget({required this.question, super.key});
final MatchQuestion question; final MatchQuestion question;
final double? maxHeight;
@override @override
State<MatchWidget> createState() => _MatchWidgetState(); State<MatchWidget> createState() => _MatchWidgetState();
@ -23,140 +22,130 @@ class _MatchWidgetState extends State<MatchWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight // Fixed spacing and padding for consistent layout
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500; final instructionPadding = 16.w;
final instructionPadding = isCompact ? 12.w : 16.w; final instructionMargin = 16.h;
final instructionMargin = isCompact ? 12.h : 16.h; final connectionPadding = 16.w;
final connectionPadding = isCompact ? 12.w : 16.w; final connectionMargin = 16.h;
final connectionMargin = isCompact ? 12.h : 16.h; final spacingAfterColumns = 16.h;
final spacingAfterColumns = isCompact ? 12.h : 16.h;
final instructionText = isCompact
? 'Connect matching items'
: 'Connect the matching items by tapping them in order';
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600; final isWideScreen = constraints.maxWidth > 600;
return ConstrainedBox( return Column(
constraints: widget.maxHeight != null mainAxisSize: MainAxisSize.min,
? BoxConstraints(maxHeight: widget.maxHeight!) children: [
: const BoxConstraints(), // Instructions
child: Column( Container(
mainAxisSize: MainAxisSize.min, padding: EdgeInsets.all(instructionPadding),
children: [ margin: EdgeInsets.only(bottom: instructionMargin),
// Instructions decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
'Connect the matching items by tapping them in order',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
// Connection display
if (_connections.isNotEmpty) ...[
Container( Container(
padding: EdgeInsets.all(instructionPadding), padding: EdgeInsets.all(connectionPadding),
margin: EdgeInsets.only(bottom: instructionMargin), margin: EdgeInsets.only(bottom: connectionMargin),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12.r), borderRadius: BorderRadius.circular(12.r),
boxShadow: [ ),
BoxShadow( child: Column(
color: Colors.black.withOpacity(0.1), crossAxisAlignment: CrossAxisAlignment.start,
blurRadius: 8, mainAxisSize: MainAxisSize.min,
offset: const Offset(0, 2), children: [
Text(
'Connected: ${_connections.length}/${widget.question.correctPairs.length}',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
), ),
SizedBox(height: 8.h),
..._buildConnectionDisplay(),
], ],
), ),
child: Text(
instructionText,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
), ),
],
// Connection display // Two columns layout
if (_connections.isNotEmpty) ...[ Flexible(
Container( child: isWideScreen
padding: EdgeInsets.all(connectionPadding), ? Row(
margin: EdgeInsets.only(bottom: connectionMargin), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: Theme.of( Expanded(
context, child: _buildColumn(
).colorScheme.surfaceContainerHighest.withOpacity(0.3), widget.question.leftItems,
borderRadius: BorderRadius.circular(12.r), isLeft: true,
), ),
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, SizedBox(width: 24.w),
mainAxisSize: MainAxisSize.min, Expanded(
children: [ child: _buildColumn(
Text( widget.question.rightItems,
'Connected: ${_connections.length}/${widget.question.correctPairs.length}', isLeft: false,
style: Theme.of(context).textTheme.titleMedium ),
?.copyWith(fontWeight: FontWeight.w600), ),
),
if (!isCompact) ...[
SizedBox(height: 8.h),
..._buildConnectionDisplay(),
], ],
], )
), : Column(
), children: [
], Text(
'Left Column',
// Two columns layout style: Theme.of(context).textTheme.titleMedium
Flexible( ?.copyWith(
child: isWideScreen fontWeight: FontWeight.w600,
? Row( color: Theme.of(context).colorScheme.primary,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ textAlign: TextAlign.center,
Expanded( ),
child: _buildColumn( SizedBox(height: 12.h),
widget.question.leftItems, Flexible(
isLeft: true, child: _buildColumn(
), widget.question.leftItems,
isLeft: true,
), ),
SizedBox(width: isCompact ? 16.w : 24.w), ),
Expanded( SizedBox(height: 24.h),
child: _buildColumn( Text(
widget.question.rightItems, 'Right Column',
isLeft: false, style: Theme.of(context).textTheme.titleMedium
), ?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.secondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
Flexible(
child: _buildColumn(
widget.question.rightItems,
isLeft: false,
), ),
], ),
) ],
: Column( ),
children: [ ),
Text(
'Left Column',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
Flexible(
child: _buildColumn(
widget.question.leftItems,
isLeft: true,
),
),
SizedBox(height: isCompact ? 16.h : 24.h),
Text(
'Right Column',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.secondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
Flexible(
child: _buildColumn(
widget.question.rightItems,
isLeft: false,
),
),
],
),
),
SizedBox(height: spacingAfterColumns), SizedBox(height: spacingAfterColumns),
@ -178,18 +167,16 @@ class _MatchWidgetState extends State<MatchWidget> {
} }
Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) { Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500; // Fixed sizes for consistent layout
final itemMargin = isCompact ? 4.h : 6.h; final itemMargin = 6.h;
final itemPadding = isCompact ? 8.w : 12.w; final itemPadding = 12.w;
final imageSize = isCompact ? 32.0 : 40.0; final imageSize = 40.0;
final fontSize = isCompact ? 14.sp : 16.sp; final fontSize = 16.sp;
final iconSize = isCompact ? 18.sp : 20.sp; final iconSize = 20.sp;
return ListView.builder( return ListView.builder(
itemCount: items.length, itemCount: items.length,
itemExtent: isCompact itemExtent: 64.0, // Fixed item height for better performance
? 52.0
: 64.0, // Fixed item height for better performance
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
final isSelected = isLeft final isSelected = isLeft
@ -240,7 +227,7 @@ class _MatchWidgetState extends State<MatchWidget> {
), ),
), ),
), ),
SizedBox(width: isCompact ? 8.w : 12.w), SizedBox(width: 12.w),
], ],
Expanded( Expanded(
child: Text( child: Text(

View file

@ -21,14 +21,12 @@ class MatrixWidget extends StatefulWidget {
required this.question, required this.question,
this.onWrongAttempt, this.onWrongAttempt,
this.onCompleted, this.onCompleted,
this.maxHeight,
super.key, super.key,
}); });
final MatrixQuestion question; final MatrixQuestion question;
final Future<void> Function()? onWrongAttempt; final Future<void> Function()? onWrongAttempt;
final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted; final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted;
final double? maxHeight;
@override @override
State<MatrixWidget> createState() => _MatrixWidgetState(); State<MatrixWidget> createState() => _MatrixWidgetState();
@ -175,64 +173,36 @@ class _MatrixWidgetState extends State<MatrixWidget>
...List<MatrixCard?>.filled(total - _slots.length, null), ...List<MatrixCard?>.filled(total - _slots.length, null),
]; ];
// Calculate available height for grid (reserve space for target container) // Fixed spacing values for consistent layout
// Target container: ~100-120px (compact) to 140-160px (full) final spacingBetween = 16.h;
final targetContainerHeight = final cellSpacing = 10.0;
widget.maxHeight != null && widget.maxHeight! < 350 ? 100.0 : 140.0; final targetPaddingH = 20.w;
final spacingBetween = widget.maxHeight != null && widget.maxHeight! < 350 final targetPaddingV = 16.h;
? 12.h
: 16.h;
final gridMaxHeight = widget.maxHeight != null return Column(
? widget.maxHeight! - targetContainerHeight - spacingBetween crossAxisAlignment: CrossAxisAlignment.stretch,
: null; mainAxisSize: MainAxisSize.min,
children: [
// Adapt spacing for grid cells Flexible(
final cellSpacing = widget.maxHeight != null && widget.maxHeight! < 350 child: GridView.builder(
? 6.0 shrinkWrap: true,
: 10.0; physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// Adapt padding for target container crossAxisCount: size,
final targetPaddingH = widget.maxHeight != null && widget.maxHeight! < 350 crossAxisSpacing: cellSpacing,
? 16.w mainAxisSpacing: cellSpacing,
: 20.w; childAspectRatio: 1.0,
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(
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(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: size,
crossAxisSpacing: cellSpacing,
mainAxisSpacing: cellSpacing,
childAspectRatio: 1.0,
),
itemCount: total,
itemBuilder: (context, index) {
final card = slots[index];
return _buildSlot(context, card);
},
),
), ),
itemCount: total,
itemBuilder: (context, index) {
final card = slots[index];
return _buildSlot(context, card);
},
), ),
SizedBox(height: spacingBetween), ),
Container( SizedBox(height: spacingBetween),
padding: EdgeInsets.symmetric( Container(
padding: EdgeInsets.symmetric(
horizontal: targetPaddingH, horizontal: targetPaddingH,
vertical: targetPaddingV, vertical: targetPaddingV,
), ),

View file

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

View file

@ -37,9 +37,19 @@
<!-- Telegram Web App SDK --> <!-- Telegram Web App SDK -->
<script src="https://telegram.org/js/telegram-web-app.js" defer></script> <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://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 --> <!-- JavaScript bridge for Adsgram SDK integration -->
<script src="foos.js"></script> <script src="foos.js"></script>
</head> </head>