diff --git a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart index 032aec6..50e0a6c 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart @@ -309,43 +309,50 @@ class _GamePageState extends State { 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 { 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( - 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( + 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(), ), ), ), diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart index f2584b0..4bd97fe 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart @@ -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); + } + }, + ); }, ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart index b7fde3f..905fe0f 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart @@ -82,90 +82,95 @@ class _InputLettersWidgetState extends State { 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 { final spacing = 12.0; return GridView.builder( - shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart index 8b64c52..b453884 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart @@ -34,7 +34,6 @@ class _MatchWidgetState extends State { final isWideScreen = constraints.maxWidth > 600; return Column( - mainAxisSize: MainAxisSize.min, children: [ // Instructions Container( @@ -86,84 +85,82 @@ class _MatchWidgetState extends State { ], // 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 items, {required bool isLeft}) { // Fixed sizes for consistent layout diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart index 153c4b4..4b5d033 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart @@ -181,11 +181,9 @@ class _MatrixWidgetState extends State 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 ), 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), + ], + ), ), - ), - ], + ], ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart index b92dc68..7e867cc 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart @@ -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), - ], - ], + ); + }, ); } } diff --git a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart index cc29441..6fc3e7b 100644 --- a/mnemo_cards_web_v2/lib/utils/card_image_utils.dart +++ b/mnemo_cards_web_v2/lib/utils/card_image_utils.dart @@ -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); } }