stuff
Some checks failed
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
Backend CI / test (push) Has been cancelled
Backend CI / build (push) Has been cancelled

This commit is contained in:
Dmitry 2025-12-20 23:28:04 +03:00
parent 82bc441b57
commit e7aab1d4ba
4 changed files with 255 additions and 89 deletions

View file

@ -169,10 +169,48 @@ class AdminTestsApiV2 {
if (v.isEmpty) return null; if (v.isEmpty) return null;
// Already a URL: return as is // Already a URL: return as is
if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) { if (CardImageStorage.isRemoteUrl(v)) {
return v; return v;
} }
// Check if path contains a full URL inside (malformed path like /api/.../cards/https://...)
// Extract the URL if found
final urlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (urlMatch != null) {
var extractedUrl = urlMatch.group(0)!;
// Remove trailing /image if present
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
// Validate it's a proper URL
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// API URL: return as is (will be handled below)
if (CardImageStorage.isApiImageUrl(v)) {
// But first check if it contains a URL inside
// If so, extract it instead
final innerUrlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (innerUrlMatch != null) {
var extractedUrl = innerUrlMatch.group(0)!;
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// Otherwise, process as normal API URL
}
// If it's a valid UUID (object ID in MinIO), generate presigned URL // If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) { if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(

View file

@ -107,6 +107,24 @@ class TestManager {
// Remote URL: keep as is // Remote URL: keep as is
if (CardImageStorage.isRemoteUrl(v)) return v; if (CardImageStorage.isRemoteUrl(v)) return v;
// Check if path contains a full URL inside (malformed path like /api/.../cards/https://...)
// Extract the URL if found
final urlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (urlMatch != null) {
final extractedUrl = urlMatch.group(0)!;
// Remove trailing /image if present
final cleanUrl = extractedUrl.endsWith('/image')
? extractedUrl.substring(0, extractedUrl.length - 6)
: extractedUrl;
// Validate it's a proper URL
final uri = Uri.tryParse(cleanUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return cleanUrl;
}
}
// If it's already a valid UUID (object ID in MinIO), keep it // If it's already a valid UUID (object ID in MinIO), keep it
if (_isUuid(v)) { if (_isUuid(v)) {
return v; return v;
@ -151,10 +169,48 @@ class TestManager {
if (v.isEmpty) return null; if (v.isEmpty) return null;
// Already a URL: return as is // Already a URL: return as is
if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) { if (CardImageStorage.isRemoteUrl(v)) {
return v; return v;
} }
// Check if path contains a full URL inside (malformed path like /api/.../cards/https://...)
// Extract the URL if found
final urlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (urlMatch != null) {
var extractedUrl = urlMatch.group(0)!;
// Remove trailing /image if present
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
// Validate it's a proper URL
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// API URL: check if it contains a URL inside
if (CardImageStorage.isApiImageUrl(v)) {
// But first check if it contains a URL inside
// If so, extract it instead
final innerUrlMatch = RegExp(r'https?://[^\s/]+[^\s]*').firstMatch(v);
if (innerUrlMatch != null) {
var extractedUrl = innerUrlMatch.group(0)!;
if (extractedUrl.endsWith('/image')) {
extractedUrl = extractedUrl.substring(0, extractedUrl.length - 6);
}
final uri = Uri.tryParse(extractedUrl);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority) {
return extractedUrl;
}
}
// Otherwise, process as normal API URL (will be handled below)
}
// If it's a valid UUID (object ID in MinIO), generate presigned URL // If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) { if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(

View file

@ -315,46 +315,74 @@ class _GamePageState extends State<GamePage> {
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 150.h, minHeight: 150.h,
), ),
child: SingleChildScrollView( child: currentQuestion is GameQuestionMatrix
child: AnimatedSwitcher( ? AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: KeyedSubtree( child: KeyedSubtree(
key: questionKey, key: questionKey,
child: Material( child: Material(
key: GamePage.questionCardKey, key: GamePage.questionCardKey,
color: colorScheme.surface, color: colorScheme.surface,
surfaceTintColor: colorScheme.surfaceTint, surfaceTintColor: colorScheme.surfaceTint,
elevation: 3, elevation: 3,
shadowColor: theme.shadowColor.withOpacity( shadowColor: theme.shadowColor.withOpacity(
theme.brightness == Brightness.dark theme.brightness == Brightness.dark
? 0.35 ? 0.35
: 0.14, : 0.14,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18.r), borderRadius: BorderRadius.circular(18.r),
side: BorderSide( side: BorderSide(
color: colorScheme.outlineVariant, color: colorScheme.outlineVariant,
),
),
child: Padding(
padding: EdgeInsets.all(
isNarrow ? 14.w : 18.w,
),
child: MatrixWidget(
question: currentQuestion.question,
),
),
), ),
), ),
child: Padding( )
padding: EdgeInsets.all( : SingleChildScrollView(
isNarrow ? 14.w : 18.w, child: AnimatedSwitcher(
), duration: const Duration(milliseconds: 300),
child: currentQuestion is GameQuestionMatrix child: KeyedSubtree(
? MatrixWidget( key: questionKey,
question: currentQuestion.question, child: Material(
) key: GamePage.questionCardKey,
: QuestionDisplay( 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: QuestionDisplay(
question: currentQuestion, question: currentQuestion,
onPlayAudio: onPlayAudio:
widget.questionAudioPlayback ?? widget.questionAudioPlayback ??
_playQuestionAudio, _playQuestionAudio,
), ),
),
),
),
), ),
), ),
),
),
),
), ),
), ),
@ -367,8 +395,43 @@ class _GamePageState extends State<GamePage> {
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 200.h, minHeight: 200.h,
), ),
child: SingleChildScrollView( child: currentQuestion.when(
child: AnimatedSwitcher( multipleChoice: (q) => 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: AnswerOptions(
question: q,
selectedAnswer:
_getSelectedAnswerForMultipleChoice(
q,
questionResults,
),
onAnswerSelected: _onAnswerSelected,
isAnswerSubmitted: isAnswerSubmitted,
isCorrect: isCorrect,
),
),
),
),
inputLetters: (q) => AnimatedSwitcher(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeInOut, switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut,
@ -388,28 +451,37 @@ class _GamePageState extends State<GamePage> {
key: ValueKey( key: ValueKey(
'question_input_${currentQuestion.hashCode}', 'question_input_${currentQuestion.hashCode}',
), ),
child: currentQuestion.when( child: InputLettersWidget(
multipleChoice: (q) => AnswerOptions( question: q,
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(),
), ),
), ),
), ),
match: (q) => 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: MatchWidget(
question: q,
),
),
),
matrix: (q) => const SizedBox.shrink(),
), ),
), ),
), ),

View file

@ -200,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(
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,
),
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
SizedBox(height: 8.h),
_buildTargetContent(context),
],
),
), ),
], 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,
),
if (widget.question.text != null &&
widget.question.text!.isNotEmpty)
SizedBox(height: 8.h),
_buildTargetContent(context),
],
),
),
],
); );
} }