stuff
Some checks are pending
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 23:11:17 +03:00
parent 9157706cad
commit 82bc441b57
7 changed files with 391 additions and 332 deletions

View file

@ -309,43 +309,50 @@ class _GamePageState extends State<GamePage> {
child: Column(
children: [
// Question display
Flexible(
Expanded(
flex: 2,
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,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: 150.h,
),
child: SingleChildScrollView(
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,
),
),
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,
),
),
),
),
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,
),
),
),
),
),
@ -354,47 +361,54 @@ class _GamePageState extends State<GamePage> {
SizedBox(height: spacingAfterQuestion),
// Answer input based on question type with smooth transitions
Flexible(
Expanded(
flex: 3,
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: ConstrainedBox(
constraints: BoxConstraints(
minHeight: 200.h,
),
child: SingleChildScrollView(
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: 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(),
),
),
);
},
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(),
),
),
),

View file

@ -46,30 +46,51 @@ class AnswerOptions extends StatelessWidget {
hasOptionItems &&
question.optionItems.any((item) => item.image != null);
// Fixed aspect ratios for consistent layout
final childAspectRatio = hasImages ? 1.1 : 4.0;
final spacing = 12.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (hasOptionItems) {
final optionItem = question.optionItems[index];
return _buildAnswerOptionFromItem(context, optionItem);
} else {
final option = question.options[index];
return _buildAnswerOption(context, option);
}
},
// Calculate childAspectRatio based on available height to fill space
// If we have available height, use it to calculate aspect ratio
double childAspectRatio;
if (constraints.maxHeight.isFinite && constraints.maxHeight > 0) {
// Calculate how many rows we need
final rows = (itemCount / crossAxisCount).ceil();
// Calculate height per item (accounting for spacing)
final totalSpacing = (rows - 1) * spacing;
final availableHeightForItems = constraints.maxHeight - totalSpacing;
final itemHeight = availableHeightForItems / rows;
// Aspect ratio = width / height
final itemWidth = constraints.maxWidth / crossAxisCount - spacing;
childAspectRatio = itemWidth / itemHeight;
// Clamp to reasonable values
childAspectRatio = childAspectRatio.clamp(
hasImages ? 0.8 : 2.0,
hasImages ? 1.5 : 6.0,
);
} else {
// Fallback to fixed aspect ratios
childAspectRatio = hasImages ? 1.1 : 4.0;
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (hasOptionItems) {
final optionItem = question.optionItems[index];
return _buildAnswerOptionFromItem(context, optionItem);
} else {
final option = question.options[index];
return _buildAnswerOption(context, option);
}
},
);
},
);
}

View file

@ -82,90 +82,95 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
builder: (context, constraints) {
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,
Expanded(
flex: 2,
child: SingleChildScrollView(
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),
),
],
SizedBox(height: spacingAfterWord),
_buildTemplateDisplay(),
],
),
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),
_buildTemplateDisplay(),
],
),
),
),
),
// Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[
Flexible(child: _buildButtonsGrid(constraints)),
SizedBox(height: spacingAfterGrid),
] else ...[
// Input field (only if no buttons)
Container(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.8,
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type the missing letters...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.r),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
),
style: TextStyle(
fontSize: 18.sp,
letterSpacing: 2,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLength: widget.question.correctAnswer.length,
onSubmitted: _submitAnswer,
),
// Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[
Expanded(
flex: 3,
child: _buildButtonsGrid(constraints),
),
SizedBox(height: spacingAfterGrid),
] else ...[
// Input field (only if no buttons)
Container(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.8,
),
SizedBox(height: spacingAfterGrid),
],
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type the missing letters...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.r),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
),
style: TextStyle(
fontSize: 18.sp,
letterSpacing: 2,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLength: widget.question.correctAnswer.length,
onSubmitted: _submitAnswer,
),
),
SizedBox(height: spacingAfterGrid),
],
// Submit button
ElevatedButton.icon(
@ -276,7 +281,6 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final spacing = 12.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,

View file

@ -34,7 +34,6 @@ class _MatchWidgetState extends State<MatchWidget> {
final isWideScreen = constraints.maxWidth > 600;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Instructions
Container(
@ -86,84 +85,82 @@ class _MatchWidgetState extends State<MatchWidget> {
],
// Two columns layout
Flexible(
child: isWideScreen
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildColumn(
Expanded(
child: SingleChildScrollView(
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,
),
),
],
)
: 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),
_buildColumn(
widget.question.leftItems,
isLeft: true,
),
),
SizedBox(width: 24.w),
Expanded(
child: _buildColumn(
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),
_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: 12.h),
Flexible(
child: _buildColumn(
widget.question.leftItems,
isLeft: true,
),
),
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,
),
),
],
),
),
SizedBox(height: spacingAfterColumns),
// Submit button
ElevatedButton.icon(
onPressed: _canSubmit ? _submitAnswer : null,
icon: const Icon(Icons.check_circle),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
],
),
),
),
),
],
);
},
);
}
SizedBox(height: spacingAfterColumns),
// Submit button
ElevatedButton.icon(
onPressed: _canSubmit ? _submitAnswer : null,
icon: const Icon(Icons.check_circle),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
),
),
],
);
},
);
}
Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
// Fixed sizes for consistent layout

View file

@ -181,11 +181,9 @@ class _MatrixWidgetState extends State<MatrixWidget>
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
Expanded(
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: size,
@ -202,42 +200,42 @@ class _MatrixWidgetState extends State<MatrixWidget>
),
SizedBox(height: spacingBetween),
Container(
padding: EdgeInsets.symmetric(
horizontal: targetPaddingH,
vertical: targetPaddingV,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(color: colorScheme.primary, width: 3),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
Text(
widget.question.text!,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
padding: EdgeInsets.symmetric(
horizontal: targetPaddingH,
vertical: targetPaddingV,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(color: colorScheme.primary, width: 3),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
SizedBox(height: 8.h),
_buildTargetContent(context),
],
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
Text(
widget.question.text!,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
SizedBox(height: 8.h),
_buildTargetContent(context),
],
),
),
),
],
],
);
}

View file

@ -80,61 +80,77 @@ class QuestionDisplay extends StatelessWidget {
final spacingAfterImage = 12.h;
final spacingAfterText = 8.h;
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),
return LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
height: constraints.maxHeight.isFinite && constraints.maxHeight > 0
? constraints.maxHeight
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Image display
if (image != null) ...[
Expanded(
flex: 3,
child: Center(
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,
),
);
},
),
),
child: Icon(
Icons.image_not_supported,
size: 48.sp,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
SizedBox(height: spacingAfterImage),
],
// Text display
if (text.isNotEmpty) ...[
Expanded(
flex: image != null ? 2 : 5,
child: Center(
child: SingleChildScrollView(
child: Text(
text,
style: theme.headlineSmall?.copyWith(
fontSize: 20.sp,
height: 1.4,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: null,
overflow: TextOverflow.visible,
),
),
),
);
},
),
),
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),
],
],
),
],
// Audio button
if (audio != null) ...[
SizedBox(height: spacingAfterText),
_QuestionAudioButton(audioUrl: audio, onPlayAudio: onPlayAudio),
],
],
);
},
);
}
}

View file

@ -79,12 +79,21 @@ class CardImageUtils {
return imageUrl;
}
// If it's a relative path (starts with /api/), convert to full URL
// If it's a relative path starting with /api/v2, extract base URL
// ApiConfigV2.baseUrl already contains /api/v2, so we need to remove it
if (imageUrl.startsWith('/api/v2/')) {
// Extract base URL without /api/v2
final baseUrl = ApiConfigV2.baseUrl;
final base = baseUrl.replaceAll('/api/v2', '');
return '$base$imageUrl';
}
// If it's a relative path starting with /, prepend base URL
if (imageUrl.startsWith('/')) {
return '${ApiConfigV2.baseUrl}$imageUrl';
}
// Fallback: assume it's a relative path and prepend base URL
// Fallback: use helper method
return ApiConfigV2.getPackCoverUrl(pack.id);
}
}