This commit is contained in:
Dmitry 2026-01-09 16:19:39 +03:00
parent 34eab39aa3
commit dfcb7a1030
10 changed files with 107 additions and 104 deletions

View file

@ -258,11 +258,11 @@ class UsersApiV2 {
} }
// Convert UserData to UserDataDto // Convert UserData to UserDataDto
final userDataModel = await _userRepository.getUserById( final userModel = await _userRepository.getUserById(
user.id!, user.id!,
withData: true, withData: true,
); );
if (userDataModel?.userData == null) { if (userModel?.userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404); return _json({'error': 'user_data_not_found'}, statusCode: 404);
} }
@ -297,12 +297,7 @@ class UsersApiV2 {
try { try {
final packId = request.url.queryParameters['packId']; final packId = request.url.queryParameters['packId'];
final userData = user.userData;
if (userData == null) {
return _json([]);
}
// Рассчитать packProgress на лету из WordStatistics // Рассчитать packProgress на лету из WordStatistics
final allPackProgress = await _statisticsCalculator final allPackProgress = await _statisticsCalculator
.calculateAllPackProgress(user.id!); .calculateAllPackProgress(user.id!);
@ -562,7 +557,7 @@ class UsersApiV2 {
return _json([]); return _json([]);
} }
final achievements = _statisticsCalculator.calculateAchievementProgress( final achievements = _statisticsCalculator.calculateAchievemegntProgress(
userData, userData,
); );
return _json(achievements.map((a) => a.toJson()).toList()); return _json(achievements.map((a) => a.toJson()).toList());

View file

@ -1,9 +1,6 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart'; import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_data_model.dart';
import '../subscription/user_subscription_model.dart';
part 'user_model.g.dart'; part 'user_model.g.dart';
@ -18,9 +15,7 @@ class UserModel {
@JsonKey(defaultValue: []) @JsonKey(defaultValue: [])
final List<String> purchases; final List<String> purchases;
final List<CardPackModel> packs; final List<CardPackModel> packs;
UserDataModel? userData;
UserSubscriptionModel? subscriptionModel;
// UserSettingsDto // UserSettingsDto
final String? userSettings; final String? userSettings;

View file

@ -231,37 +231,14 @@ class GameSessionManager {
if (answer is! String) return false; if (answer is! String) return false;
// Remove spaces from answer // Remove spaces from answer
final answerNoSpaces = answer.replaceAll(' ', ''); final answerNoSpaces = answer.replaceAll(' ', '').toLowerCase();
final correctNoSpaces = question.correctAnswer.replaceAll(' ', ''); final correctNoSpaces = question.correctAnswer.replaceAll(' ', '').toLowerCase();
// If lengths don't match, answer is wrong // If lengths don't match, answer is wrong
if (answerNoSpaces.length != correctNoSpaces.length) return false; if (answerNoSpaces.length != correctNoSpaces.length) return false;
// Build expected answer based on template
// '_' = lowercase, '|' = uppercase
final template = question.template;
final expectedAnswer = StringBuffer();
int answerLetterIndex = 0;
for (int i = 0; i < template.length && answerLetterIndex < correctNoSpaces.length; i++) {
final templateChar = template[i];
if (templateChar == '_' || templateChar == '|') {
// This is a slot - use the corresponding letter from correct answer
final correctChar = correctNoSpaces[answerLetterIndex];
if (templateChar == '|') {
// Uppercase slot - expect uppercase
expectedAnswer.write(correctChar.toUpperCase());
} else {
// Lowercase slot - expect lowercase
expectedAnswer.write(correctChar.toLowerCase());
}
answerLetterIndex++;
}
// Skip other characters in template (visible letters, spaces)
}
// Compare user answer with expected answer (case-sensitive for uppercase slots) // Compare user answer with expected answer (case-sensitive for uppercase slots)
return answerNoSpaces == expectedAnswer.toString(); return answerNoSpaces == question.correctAnswer;
} }
bool _validateMatch(MatchQuestion question, dynamic answer) { bool _validateMatch(MatchQuestion question, dynamic answer) {

View file

@ -1,5 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:game_tests/game_tests.dart'; import 'package:game_tests/game_tests.dart';
@ -521,6 +522,20 @@ class TestsStateManager extends StateManager<TestsState> {
} else if (question is MatrixTestQuestionBody) { } else if (question is MatrixTestQuestionBody) {
final id = question.id?.toString() ?? 'q_${questions.length}'; final id = question.id?.toString() ?? 'q_${questions.length}';
// Validate and log matrix question data
if (question.cards.isEmpty) {
debugPrint(
'TestsStateManager: Warning - matrix question $id has empty cards list. '
'matrixSize: ${question.matrixSize}, stages: ${question.stages.length}',
);
} else {
debugPrint(
'TestsStateManager: Converting matrix question $id with '
'${question.cards.length} cards, matrixSize: ${question.matrixSize}, '
'stages: ${question.stages.length}',
);
}
final cards = question.cards final cards = question.cards
.map( .map(
(c) => MatrixCard( (c) => MatrixCard(

View file

@ -906,7 +906,6 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
width: width, width: width,
height: height, height: height,
fit: BoxFit.cover, fit: BoxFit.cover,
useCachedNetworkImage: true,
maxWidthDiskCache: 2048, maxWidthDiskCache: 2048,
maxHeightDiskCache: 2048, maxHeightDiskCache: 2048,
fadeInDuration: const Duration(milliseconds: 100), fadeInDuration: const Duration(milliseconds: 100),

View file

@ -724,7 +724,6 @@ class _CardSide extends StatelessWidget {
card: card, card: card,
packId: packId, packId: packId,
fit: BoxFit.cover, fit: BoxFit.cover,
useCachedNetworkImage: false,
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
return Container( return Container(
color: packColor.withOpacity(0.1), color: packColor.withOpacity(0.1),

View file

@ -553,7 +553,6 @@ class _CardSide extends StatelessWidget {
card: card, card: card,
packId: packId, packId: packId,
fit: BoxFit.cover, fit: BoxFit.cover,
useCachedNetworkImage: false,
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
return Container( return Container(
color: packColor.withOpacity(0.1), color: packColor.withOpacity(0.1),
@ -575,7 +574,6 @@ class _CardSide extends StatelessWidget {
packId: packId, packId: packId,
fit: BoxFit.contain, fit: BoxFit.contain,
isBackImage: true, isBackImage: true,
useCachedNetworkImage: false,
placeholder: (context, url) { placeholder: (context, url) {
return Center( return Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(

View file

@ -33,7 +33,6 @@ class ExpiringCardImage extends StatefulWidget {
this.fadeInDuration = const Duration(milliseconds: 100), this.fadeInDuration = const Duration(milliseconds: 100),
this.fadeOutDuration = const Duration(milliseconds: 100), this.fadeOutDuration = const Duration(milliseconds: 100),
this.isBackImage = false, this.isBackImage = false,
this.useCachedNetworkImage = true,
}); });
final GameCardDto card; final GameCardDto card;
@ -48,7 +47,6 @@ class ExpiringCardImage extends StatefulWidget {
final Duration fadeInDuration; final Duration fadeInDuration;
final Duration fadeOutDuration; final Duration fadeOutDuration;
final bool isBackImage; final bool isBackImage;
final bool useCachedNetworkImage;
@override @override
State<ExpiringCardImage> createState() => _ExpiringCardImageState(); State<ExpiringCardImage> createState() => _ExpiringCardImageState();
@ -191,22 +189,6 @@ class _ExpiringCardImageState extends State<ExpiringCardImage> {
); );
if (mounted) { if (mounted) {
// Clear cache for old URL before updating
if (_currentUrl != null && widget.useCachedNetworkImage) {
try {
await CachedNetworkImage.evictFromCache(_currentUrl!);
log(
'Cleared cache for old URL: $_currentUrl',
name: 'ExpiringCardImage',
);
} catch (e) {
log(
'Failed to clear cache: $e',
name: 'ExpiringCardImage',
);
}
}
setState(() { setState(() {
_currentUrl = newUrl; _currentUrl = newUrl;
_isRefreshing = false; _isRefreshing = false;
@ -360,38 +342,26 @@ class _ExpiringCardImageState extends State<ExpiringCardImage> {
return _buildErrorWidget(context, '', null); return _buildErrorWidget(context, '', null);
} }
if (widget.useCachedNetworkImage) { final objectId = _getObjectId();
// Use key based on URL to force rebuild when URL changes // Use objectId as cacheKey so cache persists across URL refreshes
return CachedNetworkImage( final cacheKey = objectId ?? _currentUrl;
key: ValueKey('cached_image_$_currentUrl'),
imageUrl: _currentUrl!, // Use key based on objectId to force rebuild when objectId changes
fit: widget.fit, return CachedNetworkImage(
width: widget.width, key: ValueKey('cached_image_$cacheKey'),
height: widget.height, imageUrl: _currentUrl!,
maxWidthDiskCache: widget.maxWidthDiskCache, fit: widget.fit,
maxHeightDiskCache: widget.maxHeightDiskCache, width: widget.width,
fadeInDuration: widget.fadeInDuration, height: widget.height,
fadeOutDuration: widget.fadeOutDuration, maxWidthDiskCache: widget.maxWidthDiskCache,
placeholder: widget.placeholder ?? (context, url) => const SizedBox.shrink(), maxHeightDiskCache: widget.maxHeightDiskCache,
errorWidget: _buildErrorWidget, fadeInDuration: widget.fadeInDuration,
// Clear cache on error to allow retry with new URL fadeOutDuration: widget.fadeOutDuration,
cacheKey: _currentUrl, placeholder: widget.placeholder ?? (context, url) => const SizedBox.shrink(),
); errorWidget: _buildErrorWidget,
} else { // Cache by objectId (UUID) instead of URL, so cache persists when presigned URL refreshes
return Image.network( cacheKey: cacheKey,
_currentUrl!, );
key: ValueKey('network_image_$_currentUrl'),
fit: widget.fit,
width: widget.width,
height: widget.height,
errorBuilder: (context, error, stackTrace) {
return _buildErrorWidget(context, _currentUrl!, error);
},
loadingBuilder: widget.placeholder != null
? null
: (context, child, loadingProgress) => child,
);
}
} }
} }

View file

@ -81,6 +81,19 @@ class _MatrixWidgetState extends State<MatrixWidget>
} }
void _initFromQuestion(MatrixQuestion q) { void _initFromQuestion(MatrixQuestion q) {
// Validate that cards are not empty
if (q.cards.isEmpty) {
debugPrint(
'MatrixWidget: Warning - question ${q.id} has empty cards list. '
'matrixSize: ${q.matrixSize}, stages: ${q.stages.length}',
);
} else {
debugPrint(
'MatrixWidget: Initializing question ${q.id} with '
'${q.cards.length} cards, matrixSize: ${q.matrixSize}',
);
}
_slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false); _slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false);
_flipped.clear(); _flipped.clear();
_correctIdsInOrder.clear(); _correctIdsInOrder.clear();
@ -356,10 +369,25 @@ class _MatrixWidgetState extends State<MatrixWidget>
Widget _buildSlot(BuildContext context, MatrixCard? card) { Widget _buildSlot(BuildContext context, MatrixCard? card) {
if (card == null) { if (card == null) {
final colorScheme = Theme.of(context).colorScheme;
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(24.r)), decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(
color: colorScheme.outlineVariant,
width: 1,
),
),
child: Center(
child: Icon(
Icons.help_outline,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
size: 32.sp,
),
),
), ),
); );
} }
@ -637,10 +665,10 @@ class _MatrixFlipCard extends StatelessWidget {
} }
// If both image and text, show text on top and image below // If both image and text, show text on top and image below
return Column( if (hasImage && hasText) {
crossAxisAlignment: CrossAxisAlignment.stretch, return Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
if (hasText) children: [
Padding( Padding(
padding: EdgeInsets.fromLTRB(2.w, 12.h, 2.w, 8.h), padding: EdgeInsets.fromLTRB(2.w, 12.h, 2.w, 8.h),
child: Column( child: Column(
@ -673,7 +701,6 @@ class _MatrixFlipCard extends StatelessWidget {
], ],
), ),
), ),
if (hasImage)
Expanded( Expanded(
child: Image.network( child: Image.network(
card.image!, card.image!,
@ -689,7 +716,36 @@ class _MatrixFlipCard extends StatelessWidget {
}, },
), ),
), ),
], ],
);
}
// Fallback: if no image and no text, show card ID as placeholder
return Center(
child: Padding(
padding: EdgeInsets.all(24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.help_outline,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
size: 48.sp,
),
SizedBox(height: 8.h),
Text(
card.id,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withOpacity(0.7),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
); );
} }
} }

View file

@ -255,7 +255,6 @@ class PackCardItem extends StatelessWidget {
width: cardWidth, width: cardWidth,
height: cardHeight, height: cardHeight,
fit: BoxFit.contain, fit: BoxFit.contain,
useCachedNetworkImage: false,
placeholder: (context, url) { placeholder: (context, url) {
return Center( return Center(
child: SizedBox( child: SizedBox(