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( child: Column(
children: [ children: [
// Question display // Question display
Flexible( Expanded(
flex: 2, flex: 2,
child: AnimatedSwitcher( child: ConstrainedBox(
duration: const Duration(milliseconds: 300), constraints: BoxConstraints(
child: KeyedSubtree( minHeight: 150.h,
key: questionKey, ),
child: Material( child: SingleChildScrollView(
key: GamePage.questionCardKey, child: AnimatedSwitcher(
color: colorScheme.surface, duration: const Duration(milliseconds: 300),
surfaceTintColor: colorScheme.surfaceTint, child: KeyedSubtree(
elevation: 3, key: questionKey,
shadowColor: theme.shadowColor.withOpacity( child: Material(
theme.brightness == Brightness.dark key: GamePage.questionCardKey,
? 0.35 color: colorScheme.surface,
: 0.14, surfaceTintColor: colorScheme.surfaceTint,
), elevation: 3,
shape: RoundedRectangleBorder( shadowColor: theme.shadowColor.withOpacity(
borderRadius: BorderRadius.circular(18.r), theme.brightness == Brightness.dark
side: BorderSide( ? 0.35
color: colorScheme.outlineVariant, : 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), SizedBox(height: spacingAfterQuestion),
// Answer input based on question type with smooth transitions // Answer input based on question type with smooth transitions
Flexible( Expanded(
flex: 3, flex: 3,
child: AnimatedSwitcher( child: ConstrainedBox(
duration: const Duration(milliseconds: 400), constraints: BoxConstraints(
switchInCurve: Curves.easeInOut, minHeight: 200.h,
switchOutCurve: Curves.easeInOut, ),
transitionBuilder: (child, animation) { child: SingleChildScrollView(
return FadeTransition( child: AnimatedSwitcher(
opacity: animation, duration: const Duration(milliseconds: 400),
child: SlideTransition( switchInCurve: Curves.easeInOut,
position: Tween<Offset>( switchOutCurve: Curves.easeInOut,
begin: const Offset(0.05, 0), transitionBuilder: (child, animation) {
end: Offset.zero, return FadeTransition(
).animate(animation), opacity: animation,
child: child, 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 && hasOptionItems &&
question.optionItems.any((item) => item.image != null); question.optionItems.any((item) => item.image != null);
// Fixed aspect ratios for consistent layout
final childAspectRatio = hasImages ? 1.1 : 4.0;
final spacing = 12.0; final spacing = 12.0;
return GridView.builder( // Calculate childAspectRatio based on available height to fill space
shrinkWrap: true, // If we have available height, use it to calculate aspect ratio
physics: const NeverScrollableScrollPhysics(), double childAspectRatio;
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( if (constraints.maxHeight.isFinite && constraints.maxHeight > 0) {
crossAxisCount: crossAxisCount, // Calculate how many rows we need
crossAxisSpacing: spacing, final rows = (itemCount / crossAxisCount).ceil();
mainAxisSpacing: spacing, // Calculate height per item (accounting for spacing)
childAspectRatio: childAspectRatio, final totalSpacing = (rows - 1) * spacing;
), final availableHeightForItems = constraints.maxHeight - totalSpacing;
itemCount: itemCount, final itemHeight = availableHeightForItems / rows;
itemBuilder: (context, index) { // Aspect ratio = width / height
if (hasOptionItems) { final itemWidth = constraints.maxWidth / crossAxisCount - spacing;
final optionItem = question.optionItems[index]; childAspectRatio = itemWidth / itemHeight;
return _buildAnswerOptionFromItem(context, optionItem); // Clamp to reasonable values
} else { childAspectRatio = childAspectRatio.clamp(
final option = question.options[index]; hasImages ? 0.8 : 2.0,
return _buildAnswerOption(context, option); 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) { builder: (context, constraints) {
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Display the template with current input // Display the template with current input
Flexible( Expanded(
child: Container( flex: 2,
padding: EdgeInsets.all(containerPadding), child: SingleChildScrollView(
margin: EdgeInsets.only(bottom: containerMargin), child: Container(
decoration: BoxDecoration( padding: EdgeInsets.all(containerPadding),
color: Theme.of(context).colorScheme.surface, margin: EdgeInsets.only(bottom: containerMargin),
borderRadius: BorderRadius.circular(16.r), decoration: BoxDecoration(
boxShadow: [ color: Theme.of(context).colorScheme.surface,
BoxShadow( borderRadius: BorderRadius.circular(16.r),
color: Colors.black.withOpacity(0.1), boxShadow: [
blurRadius: 8, BoxShadow(
offset: const Offset(0, 2), 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), ),
_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) // Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[ if (widget.question.buttons.isNotEmpty) ...[
Flexible(child: _buildButtonsGrid(constraints)), Expanded(
SizedBox(height: spacingAfterGrid), flex: 3,
] else ...[ child: _buildButtonsGrid(constraints),
// Input field (only if no buttons) ),
Container( SizedBox(height: spacingAfterGrid),
constraints: BoxConstraints( ] else ...[
maxWidth: constraints.maxWidth * 0.8, // Input field (only if no buttons)
), Container(
child: TextField( constraints: BoxConstraints(
controller: _controller, maxWidth: constraints.maxWidth * 0.8,
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), 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 // Submit button
ElevatedButton.icon( ElevatedButton.icon(
@ -276,7 +281,6 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final spacing = 12.0; final spacing = 12.0;
return GridView.builder( return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,

View file

@ -34,7 +34,6 @@ class _MatchWidgetState extends State<MatchWidget> {
final isWideScreen = constraints.maxWidth > 600; final isWideScreen = constraints.maxWidth > 600;
return Column( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Instructions // Instructions
Container( Container(
@ -86,84 +85,82 @@ class _MatchWidgetState extends State<MatchWidget> {
], ],
// Two columns layout // Two columns layout
Flexible( Expanded(
child: isWideScreen child: SingleChildScrollView(
? Row( child: isWideScreen
crossAxisAlignment: CrossAxisAlignment.start, ? Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: _buildColumn( 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, widget.question.leftItems,
isLeft: true, isLeft: true,
), ),
), SizedBox(height: 24.h),
SizedBox(width: 24.w), Text(
Expanded( 'Right Column',
child: _buildColumn( 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, widget.question.rightItems,
isLeft: false, 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}) { Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
// Fixed sizes for consistent layout // Fixed sizes for consistent layout

View file

@ -181,11 +181,9 @@ class _MatrixWidgetState extends State<MatrixWidget>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( Expanded(
child: GridView.builder( child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: size, crossAxisCount: size,
@ -202,42 +200,42 @@ class _MatrixWidgetState extends State<MatrixWidget>
), ),
SizedBox(height: spacingBetween), SizedBox(height: spacingBetween),
Container( Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: targetPaddingH, horizontal: targetPaddingH,
vertical: targetPaddingV, vertical: targetPaddingV,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
borderRadius: BorderRadius.circular(24.r), borderRadius: BorderRadius.circular(24.r),
border: Border.all(color: colorScheme.primary, width: 3), border: Border.all(color: colorScheme.primary, width: 3),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: colorScheme.shadow.withOpacity(0.3), color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 10), 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,
), ),
if (widget.question.text != null && ],
widget.question.text!.isNotEmpty) ),
SizedBox(height: 8.h), child: Column(
_buildTargetContent(context), 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 spacingAfterImage = 12.h;
final spacingAfterText = 8.h; final spacingAfterText = 8.h;
return Column( return LayoutBuilder(
mainAxisAlignment: MainAxisAlignment.center, builder: (context, constraints) {
mainAxisSize: MainAxisSize.min, return SizedBox(
children: [ height: constraints.maxHeight.isFinite && constraints.maxHeight > 0
// Image display ? constraints.maxHeight
if (image != null) ...[ : null,
Flexible( child: Column(
child: Image.network( mainAxisAlignment: MainAxisAlignment.center,
image, children: [
fit: BoxFit.contain, // Image display
errorBuilder: (context, error, stackTrace) { if (image != null) ...[
return Container( Expanded(
height: 120.h, flex: 3,
width: 120.w, child: Center(
decoration: BoxDecoration( child: Image.network(
color: Theme.of( image,
context, fit: BoxFit.contain,
).colorScheme.surfaceContainerHighest, errorBuilder: (context, error, stackTrace) {
borderRadius: BorderRadius.circular(8.r), 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, SizedBox(height: spacingAfterImage),
size: 48.sp, ],
color: Theme.of(context).colorScheme.onSurfaceVariant,
// 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 // Audio button
if (text.isNotEmpty) ...[ if (audio != null) ...[
Flexible( SizedBox(height: spacingAfterText),
child: Text( _QuestionAudioButton(audioUrl: audio, onPlayAudio: onPlayAudio),
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

@ -79,12 +79,21 @@ class CardImageUtils {
return imageUrl; 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('/')) { if (imageUrl.startsWith('/')) {
return '${ApiConfigV2.baseUrl}$imageUrl'; return '${ApiConfigV2.baseUrl}$imageUrl';
} }
// Fallback: assume it's a relative path and prepend base URL // Fallback: use helper method
return ApiConfigV2.getPackCoverUrl(pack.id); return ApiConfigV2.getPackCoverUrl(pack.id);
} }
} }