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
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:
parent
0f49280805
commit
dc01b02508
11 changed files with 368 additions and 502 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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,
|
||||
original: c.original,
|
||||
translation: c.translation,
|
||||
);
|
||||
}
|
||||
// Always include original and translation for back side
|
||||
// Include image if available (for image-based question types)
|
||||
return MatrixCardDto(
|
||||
id: 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,47 +311,40 @@ 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(
|
||||
key: questionKey,
|
||||
child: Material(
|
||||
key: GamePage.questionCardKey,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: colorScheme.surfaceTint,
|
||||
elevation: 3,
|
||||
shadowColor: theme.shadowColor.withOpacity(
|
||||
theme.brightness == Brightness.dark
|
||||
? 0.35
|
||||
: 0.14,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: KeyedSubtree(
|
||||
key: questionKey,
|
||||
child: Material(
|
||||
key: GamePage.questionCardKey,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: colorScheme.surfaceTint,
|
||||
elevation: 3,
|
||||
shadowColor: theme.shadowColor.withOpacity(
|
||||
theme.brightness == Brightness.dark
|
||||
? 0.35
|
||||
: 0.14,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.r),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.r),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
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: Padding(
|
||||
padding: EdgeInsets.all(
|
||||
isNarrow ? 14.w : 18.w,
|
||||
),
|
||||
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
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: answerMaxHeight,
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey(
|
||||
'question_input_${currentQuestion.hashCode}',
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
child: currentQuestion.when(
|
||||
multipleChoice: (q) => AnswerOptions(
|
||||
question: q,
|
||||
selectedAnswer:
|
||||
_getSelectedAnswerForMultipleChoice(
|
||||
q,
|
||||
questionResults,
|
||||
),
|
||||
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(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey(
|
||||
'question_input_${currentQuestion.hashCode}',
|
||||
),
|
||||
child: currentQuestion.when(
|
||||
multipleChoice: (q) => AnswerOptions(
|
||||
question: q,
|
||||
selectedAnswer:
|
||||
_getSelectedAnswerForMultipleChoice(
|
||||
q,
|
||||
questionResults,
|
||||
),
|
||||
onAnswerSelected: _onAnswerSelected,
|
||||
isAnswerSubmitted: isAnswerSubmitted,
|
||||
isCorrect: isCorrect,
|
||||
),
|
||||
inputLetters: (q) => InputLettersWidget(
|
||||
question: q,
|
||||
),
|
||||
match: (q) => MatchWidget(
|
||||
question: q,
|
||||
),
|
||||
matrix: (q) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,8 +69,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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,71 +71,63 @@ 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(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Display the template with current input
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(containerPadding),
|
||||
margin: EdgeInsets.only(bottom: containerMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Display the template with current input
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(containerPadding),
|
||||
margin: EdgeInsets.only(bottom: containerMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
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)
|
||||
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(),
|
||||
],
|
||||
),
|
||||
SizedBox(height: spacingAfterWord),
|
||||
_buildTemplateDisplay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Buttons with images or text (if available)
|
||||
if (widget.question.buttons.isNotEmpty) ...[
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,140 +22,130 @@ 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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Instructions
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Instructions
|
||||
Container(
|
||||
padding: EdgeInsets.all(instructionPadding),
|
||||
margin: EdgeInsets.only(bottom: instructionMargin),
|
||||
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(
|
||||
padding: EdgeInsets.all(instructionPadding),
|
||||
margin: EdgeInsets.only(bottom: instructionMargin),
|
||||
padding: EdgeInsets.all(connectionPadding),
|
||||
margin: EdgeInsets.only(bottom: connectionMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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
|
||||
if (_connections.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.all(connectionPadding),
|
||||
margin: EdgeInsets.only(bottom: connectionMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Connected: ${_connections.length}/${widget.question.correctPairs.length}',
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (!isCompact) ...[
|
||||
SizedBox(height: 8.h),
|
||||
..._buildConnectionDisplay(),
|
||||
// Two columns layout
|
||||
Flexible(
|
||||
child: isWideScreen
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildColumn(
|
||||
widget.question.leftItems,
|
||||
isLeft: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 24.w),
|
||||
Expanded(
|
||||
child: _buildColumn(
|
||||
widget.question.rightItems,
|
||||
isLeft: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Two columns layout
|
||||
Flexible(
|
||||
child: isWideScreen
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildColumn(
|
||||
widget.question.leftItems,
|
||||
isLeft: true,
|
||||
),
|
||||
)
|
||||
: 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: 12.h),
|
||||
Flexible(
|
||||
child: _buildColumn(
|
||||
widget.question.leftItems,
|
||||
isLeft: true,
|
||||
),
|
||||
SizedBox(width: isCompact ? 16.w : 24.w),
|
||||
Expanded(
|
||||
child: _buildColumn(
|
||||
widget.question.rightItems,
|
||||
isLeft: false,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 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: 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),
|
||||
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,64 +173,36 @@ 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(
|
||||
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);
|
||||
},
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
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);
|
||||
},
|
||||
),
|
||||
SizedBox(height: spacingBetween),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
),
|
||||
SizedBox(height: spacingBetween),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: targetPaddingH,
|
||||
vertical: targetPaddingV,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,93 +76,65 @@ 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(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Image display
|
||||
if (image != null) ...[
|
||||
Flexible(
|
||||
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),
|
||||
],
|
||||
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
SizedBox(height: spacingAfterImage),
|
||||
],
|
||||
),
|
||||
|
||||
// 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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue