1017 lines
34 KiB
Dart
1017 lines
34 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_puzzle.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
|
|
|
enum _Kind { mcq, puzzle, simple }
|
|
|
|
_Kind _kindOf(QuizQuestion q) {
|
|
switch (q.validationQuestionType?.value) {
|
|
case 2:
|
|
return _Kind.puzzle;
|
|
case 0:
|
|
return _Kind.simple;
|
|
default:
|
|
return _Kind.mcq;
|
|
}
|
|
}
|
|
|
|
String _stripHtml(String html) => html
|
|
.replaceAll(RegExp(r'<[^>]*>'), ' ')
|
|
.replaceAll(' ', ' ')
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
|
|
String _normalize(String text) {
|
|
const accents = 'àâäáãåçèéêëìîïíòôöóõùûüúÿñ';
|
|
const plain = 'aaaaaaceeeeiiiiooooouuuuyn';
|
|
var s = _stripHtml(text).toLowerCase();
|
|
final buffer = StringBuffer();
|
|
for (final ch in s.split('')) {
|
|
final idx = accents.indexOf(ch);
|
|
buffer.write(idx >= 0 ? plain[idx] : ch);
|
|
}
|
|
return buffer.toString().trim();
|
|
}
|
|
|
|
/// Une réponse "Simple" purement numérique s'affiche comme un digicode
|
|
/// (pavé de boutons) plutôt qu'un champ de texte libre.
|
|
bool _isDigicode(String expected) =>
|
|
expected.isNotEmpty && RegExp(r'^[0-9]+$').hasMatch(expected);
|
|
|
|
/// État + logique d'un défi d'étape (QCM / puzzle / réponse ouverte), en miroir
|
|
/// de `ProgressView`/`ChallengeModal`/`StepQuiz` de visitapp-web. Le décompte du
|
|
/// timer vit ici (il démarre à l'ouverture du défi et continue même overlay fermé).
|
|
class GuidedStepChallengeController extends ChangeNotifier {
|
|
final List<QuizQuestion> questions;
|
|
final bool isGame;
|
|
final bool requireSuccess;
|
|
final bool hasTimer;
|
|
final int timerSeconds;
|
|
final VisitAppContext visitAppContext;
|
|
|
|
GuidedStepChallengeController({
|
|
required this.questions,
|
|
required this.isGame,
|
|
required this.requireSuccess,
|
|
required this.hasTimer,
|
|
required this.timerSeconds,
|
|
required this.visitAppContext,
|
|
}) {
|
|
_remaining = timerSeconds;
|
|
}
|
|
|
|
/// Construit la liste combinée et triée des questions valides d'une étape.
|
|
static List<QuizQuestion> buildQuestions(GuidedStepDTO step) {
|
|
final all = [...(step.quizQuestions ?? [])];
|
|
final valid = all.where((q) {
|
|
switch (_kindOf(q)) {
|
|
case _Kind.puzzle:
|
|
return q.puzzleImage?.url != null;
|
|
case _Kind.simple:
|
|
return q.responses.isNotEmpty;
|
|
case _Kind.mcq:
|
|
return q.responses.isNotEmpty;
|
|
}
|
|
}).toList()
|
|
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
|
return valid;
|
|
}
|
|
|
|
final Map<int, int> _mcq = {};
|
|
final Set<int> _puzzle = {};
|
|
final Map<int, String> _simple = {};
|
|
final Set<int> _simpleChecked = {};
|
|
|
|
int challengeIndex = 0;
|
|
bool showResult = false;
|
|
bool quizPassed = false;
|
|
bool open = false;
|
|
|
|
bool timerStarted = false;
|
|
bool expired = false;
|
|
int _remaining = 0;
|
|
Timer? _timer;
|
|
|
|
int get remaining => _remaining;
|
|
bool get hasQuiz => questions.isNotEmpty;
|
|
|
|
bool _correct(QuizQuestion q) {
|
|
switch (_kindOf(q)) {
|
|
case _Kind.puzzle:
|
|
return _puzzle.contains(q.id);
|
|
case _Kind.simple:
|
|
return _simpleCorrect(q, _simple[q.id] ?? '');
|
|
case _Kind.mcq:
|
|
return _mcqCorrect(q, _mcq[q.id]);
|
|
}
|
|
}
|
|
|
|
bool _mcqCorrect(QuizQuestion q, int? idx) {
|
|
if (idx == null) return false;
|
|
final sorted = [...q.responses]
|
|
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
|
return idx >= 0 && idx < sorted.length && sorted[idx].isGood == true;
|
|
}
|
|
|
|
String _expectedSimple(QuizQuestion q) =>
|
|
TranslationHelper.getWithResource(q.responses.first.label, visitAppContext);
|
|
|
|
bool _simpleCorrect(QuizQuestion q, String given) {
|
|
final expected = _normalize(_expectedSimple(q));
|
|
return expected.isNotEmpty && _normalize(given) == expected;
|
|
}
|
|
|
|
int get wrongCount => questions.where((q) => !_correct(q)).length;
|
|
|
|
bool get allAnsweredCorrectly =>
|
|
hasQuiz && questions.isNotEmpty && wrongCount == 0;
|
|
|
|
bool get canAdvance => !hasQuiz || !requireSuccess || quizPassed;
|
|
|
|
bool get _timerActive =>
|
|
timerStarted && !expired && !(hasQuiz && (quizPassed || allAnsweredCorrectly));
|
|
|
|
/// Ouvre l'overlay. Retourne true si le chrono vient d'être démarré.
|
|
bool openChallenge() {
|
|
open = true;
|
|
var started = false;
|
|
if (hasTimer && !timerStarted) {
|
|
timerStarted = true;
|
|
started = true;
|
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
|
}
|
|
notifyListeners();
|
|
return started;
|
|
}
|
|
|
|
void close() {
|
|
open = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _tick(Timer t) {
|
|
if (!_timerActive) {
|
|
t.cancel();
|
|
return;
|
|
}
|
|
if (_remaining <= 1) {
|
|
_remaining = 0;
|
|
expired = true;
|
|
t.cancel();
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
_remaining -= 1;
|
|
notifyListeners();
|
|
}
|
|
|
|
void answerMcq(int qId, int sortedIndex) {
|
|
_mcq[qId] = sortedIndex;
|
|
notifyListeners();
|
|
}
|
|
|
|
void solvePuzzle(int qId) {
|
|
_puzzle.add(qId);
|
|
notifyListeners();
|
|
}
|
|
|
|
// Le texte est transitoire jusqu'à la validation : pas de notifyListeners ici
|
|
// (le champ de saisie gère son propre état pour éviter les rebuilds).
|
|
void setSimple(int qId, String text) {
|
|
_simple[qId] = text;
|
|
}
|
|
|
|
void checkSimple(int qId) {
|
|
_simpleChecked.add(qId);
|
|
notifyListeners();
|
|
}
|
|
|
|
void nextItem() {
|
|
if (challengeIndex < questions.length - 1) challengeIndex++;
|
|
notifyListeners();
|
|
}
|
|
|
|
void submit() {
|
|
quizPassed = wrongCount == 0;
|
|
showResult = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void retryWrong() {
|
|
final wrongIds = questions.where((q) => !_correct(q)).map((q) => q.id).toSet();
|
|
_mcq.removeWhere((id, _) => wrongIds.contains(id));
|
|
_simple.removeWhere((id, _) => wrongIds.contains(id));
|
|
_simpleChecked.removeWhere((id) => wrongIds.contains(id));
|
|
final firstWrong = questions.indexWhere((q) => wrongIds.contains(q.id));
|
|
challengeIndex = firstWrong >= 0 ? firstWrong : 0;
|
|
showResult = false;
|
|
quizPassed = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
// Lecture d'état par cellule
|
|
int? mcqAnswer(int qId) => _mcq[qId];
|
|
bool puzzleSolved(int qId) => _puzzle.contains(qId);
|
|
String simpleText(int qId) => _simple[qId] ?? '';
|
|
bool simpleChecked(int qId) => _simpleChecked.contains(qId);
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
// ─── Overlay ──────────────────────────────────────────────────────────────────
|
|
|
|
class GuidedStepChallengeOverlay extends StatelessWidget {
|
|
final GuidedStepChallengeController controller;
|
|
final int stepIndex;
|
|
final Color accentColor;
|
|
final bool isGame;
|
|
final VisitAppContext visitAppContext;
|
|
|
|
const GuidedStepChallengeOverlay({
|
|
Key? key,
|
|
required this.controller,
|
|
required this.stepIndex,
|
|
required this.accentColor,
|
|
required this.isGame,
|
|
required this.visitAppContext,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: controller,
|
|
builder: (context, _) {
|
|
if (!controller.open) return const SizedBox.shrink();
|
|
final sheetBg = isGame ? const Color(0xFF0B1018) : Colors.white;
|
|
|
|
return Positioned.fill(
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: controller.close,
|
|
child: Container(color: Colors.black.withOpacity(0.45)),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: sheetBg,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
|
child: Container(
|
|
width: 36,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: isGame
|
|
? Colors.white.withOpacity(0.15)
|
|
: const Color(0xFFD4C9B8),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: isGame
|
|
? const Color(0xFFD4AF37).withOpacity(0.12)
|
|
: accentColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
'DÉFI · ÉTAPE ${stepIndex + 1}',
|
|
style: TextStyle(
|
|
color: accentColor,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: controller.close,
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isGame
|
|
? Colors.white.withOpacity(0.08)
|
|
: const Color(0xFFF0EBE3),
|
|
),
|
|
child: Icon(Icons.close,
|
|
size: 16,
|
|
color: isGame
|
|
? const Color(0xFF8a8068)
|
|
: const Color(0xFF6B7B86)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
child: Column(
|
|
children: [
|
|
if (controller.hasTimer) ...[
|
|
GuidedStepTimer(
|
|
totalSeconds: controller.timerSeconds,
|
|
remainingSeconds: controller.remaining,
|
|
isGame: isGame,
|
|
),
|
|
const SizedBox(height: 14),
|
|
],
|
|
_StepQuiz(
|
|
controller: controller,
|
|
accentColor: accentColor,
|
|
isGame: isGame,
|
|
visitAppContext: visitAppContext,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StepQuiz extends StatelessWidget {
|
|
final GuidedStepChallengeController controller;
|
|
final Color accentColor;
|
|
final bool isGame;
|
|
final VisitAppContext visitAppContext;
|
|
|
|
const _StepQuiz({
|
|
required this.controller,
|
|
required this.accentColor,
|
|
required this.isGame,
|
|
required this.visitAppContext,
|
|
});
|
|
|
|
Color get _ink => isGame ? const Color(0xFFF4ECD8) : const Color(0xFF1E2A33);
|
|
Color get _muted => isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
|
|
|
|
String _label(List<TranslationAndResourceDTO>? l) =>
|
|
TranslationHelper.getWithResource(l, visitAppContext);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = controller;
|
|
final questions = c.questions;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isGame
|
|
? const Color(0xFFD4AF37).withOpacity(0.2)
|
|
: const Color(0xFFECE6DC)),
|
|
),
|
|
child: c.showResult
|
|
? _buildResult(context)
|
|
: questions.isEmpty
|
|
? const SizedBox.shrink()
|
|
: _buildQuestion(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildResult(BuildContext context) {
|
|
final c = controller;
|
|
if (c.quizPassed) {
|
|
return Column(
|
|
children: [
|
|
_resultBadge(const Color(0xFF2E9E6B), Icons.check),
|
|
const SizedBox(height: 12),
|
|
Text('Bien joué !',
|
|
style: TextStyle(color: _ink, fontSize: 18, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 6),
|
|
Text('Continuez votre parcours.',
|
|
style: TextStyle(color: _muted, fontSize: 14)),
|
|
],
|
|
);
|
|
}
|
|
final total = c.questions.length;
|
|
final good = total - c.wrongCount;
|
|
return Column(
|
|
children: [
|
|
_resultBadge(const Color(0xFFD14343), Icons.close),
|
|
const SizedBox(height: 12),
|
|
Text('Pas tout à fait…',
|
|
style: TextStyle(color: _ink, fontSize: 18, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 6),
|
|
Text('$good / $total bonnes réponses.',
|
|
style: TextStyle(color: _muted, fontSize: 14)),
|
|
const SizedBox(height: 4),
|
|
Text('Observez bien les détails autour de vous.',
|
|
textAlign: TextAlign.center, style: TextStyle(color: _muted, fontSize: 14)),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: c.retryWrong,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: accentColor,
|
|
foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 13),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
elevation: 0,
|
|
),
|
|
child: Text(
|
|
c.wrongCount == 1 ? 'Corriger cette réponse' : 'Corriger ces réponses',
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _resultBadge(Color color, IconData icon) => Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color.withOpacity(0.14)),
|
|
child: Center(
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
|
child: Icon(icon, color: Colors.white, size: 26),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _buildQuestion(BuildContext context) {
|
|
final c = controller;
|
|
final questions = c.questions;
|
|
final current = questions[c.challengeIndex];
|
|
final isLast = c.challengeIndex >= questions.length - 1;
|
|
final kind = _kindOf(current);
|
|
|
|
final hasAnswer = kind == _Kind.mcq
|
|
? c.mcqAnswer(current.id) != null
|
|
: kind == _Kind.puzzle
|
|
? c.puzzleSolved(current.id)
|
|
: c.simpleChecked(current.id);
|
|
final isCorrectPick = kind == _Kind.mcq
|
|
? c._mcqCorrect(current, c.mcqAnswer(current.id))
|
|
: kind == _Kind.puzzle
|
|
? true
|
|
: c._simpleCorrect(current, c.simpleText(current.id));
|
|
final revealImmediately = !isGame;
|
|
final checked = isGame ? (hasAnswer && isCorrectPick) : hasAnswer;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: List.generate(questions.length, (i) {
|
|
return Expanded(
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(2),
|
|
color: i <= c.challengeIndex
|
|
? accentColor
|
|
: (isGame ? Colors.white.withOpacity(0.12) : const Color(0xFFE2DCD2)),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text('QUESTION ${c.challengeIndex + 1} / ${questions.length}',
|
|
style: TextStyle(
|
|
fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3, color: _muted)),
|
|
const SizedBox(height: 10),
|
|
HtmlWidget(_label(current.label),
|
|
textStyle: TextStyle(color: _ink, fontSize: 15, fontWeight: FontWeight.w600, height: 1.4)),
|
|
const SizedBox(height: 12),
|
|
if (kind == _Kind.mcq)
|
|
_buildMcq(current, hasAnswer, checked, revealImmediately)
|
|
else if (kind == _Kind.puzzle)
|
|
_buildPuzzle(current)
|
|
else
|
|
_buildSimple(current, hasAnswer, checked, revealImmediately),
|
|
if (checked) ...[
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: isLast ? c.submit : c.nextItem,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: accentColor,
|
|
foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
elevation: 0,
|
|
),
|
|
child: Text(isLast ? 'Terminer le défi' : 'Question suivante',
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMcq(
|
|
QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) {
|
|
final sorted = [...current.responses]
|
|
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
|
final chosen = controller.mcqAnswer(current.id);
|
|
|
|
return Column(
|
|
children: [
|
|
for (int ri = 0; ri < sorted.length; ri++)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _mcqOption(current, sorted[ri], ri, chosen, hasAnswer, checked, revealImmediately),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _mcqOption(QuizQuestion current, ResponseDTO r, int ri, int? chosen,
|
|
bool hasAnswer, bool checked, bool revealImmediately) {
|
|
final isSelected = chosen == ri;
|
|
var bg = isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1);
|
|
var border = isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2);
|
|
if (hasAnswer) {
|
|
if (isSelected) {
|
|
if (r.isGood == true) {
|
|
bg = const Color(0xFF2E9E6B).withOpacity(0.14);
|
|
border = const Color(0xFF2E9E6B);
|
|
} else {
|
|
bg = const Color(0xFFD14343).withOpacity(0.12);
|
|
border = const Color(0xFFD14343);
|
|
}
|
|
} else if (revealImmediately && r.isGood == true) {
|
|
bg = const Color(0xFF2E9E6B).withOpacity(0.14);
|
|
border = const Color(0xFF2E9E6B);
|
|
}
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: checked ? null : () => controller.answerMcq(current.id, ri),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: border, width: 1.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 30,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(9),
|
|
color: isSelected
|
|
? accentColor
|
|
: (isGame ? Colors.white.withOpacity(0.08) : const Color(0xFFE8E3DA)),
|
|
),
|
|
child: Text(
|
|
String.fromCharCode(65 + ri),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 13,
|
|
color: isSelected
|
|
? (isGame ? const Color(0xFF1a1305) : Colors.white)
|
|
: (isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: HtmlWidget(_label(r.label),
|
|
textStyle: TextStyle(color: _ink, fontSize: 15, fontWeight: FontWeight.w500)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPuzzle(QuizQuestion current) {
|
|
final rows = (current.puzzleRows ?? 3).clamp(2, 6).toInt();
|
|
final cols = (current.puzzleCols ?? 3).clamp(2, 6).toInt();
|
|
void onSolved() => controller.solvePuzzle(current.id);
|
|
|
|
if (current.isSlidingPuzzle == true) {
|
|
return GuidedPathSlidingPuzzle(
|
|
key: ValueKey('puzzle-${current.id}'),
|
|
imageUrl: current.puzzleImage!.url!,
|
|
rows: rows,
|
|
cols: cols,
|
|
accentColor: accentColor,
|
|
isGame: isGame,
|
|
onSolved: onSolved,
|
|
);
|
|
}
|
|
return GuidedPathPuzzle(
|
|
key: ValueKey('puzzle-${current.id}'),
|
|
imageUrl: current.puzzleImage!.url!,
|
|
rows: rows,
|
|
cols: cols,
|
|
accentColor: accentColor,
|
|
isGame: isGame,
|
|
onSolved: onSolved,
|
|
);
|
|
}
|
|
|
|
Widget _buildSimple(
|
|
QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) {
|
|
final expectedRaw = _stripHtml(_label(current.responses.first.label));
|
|
if (_isDigicode(expectedRaw)) {
|
|
return _DigicodeField(
|
|
key: ValueKey('digicode-${current.id}'),
|
|
controller: controller,
|
|
question: current,
|
|
accentColor: accentColor,
|
|
isGame: isGame,
|
|
ink: _ink,
|
|
muted: _muted,
|
|
expectedLength: expectedRaw.length,
|
|
expectedLabel: expectedRaw,
|
|
hasAnswer: hasAnswer,
|
|
checked: checked,
|
|
revealImmediately: revealImmediately,
|
|
);
|
|
}
|
|
return _SimpleAnswerField(
|
|
key: ValueKey('simple-${current.id}'),
|
|
controller: controller,
|
|
question: current,
|
|
accentColor: accentColor,
|
|
isGame: isGame,
|
|
ink: _ink,
|
|
muted: _muted,
|
|
expectedLabel: expectedRaw,
|
|
hasAnswer: hasAnswer,
|
|
checked: checked,
|
|
revealImmediately: revealImmediately,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Champ de réponse ouverte, autonome pour préserver le curseur pendant la
|
|
/// saisie (le contrôleur de défi ne rebuild pas à chaque frappe).
|
|
class _SimpleAnswerField extends StatefulWidget {
|
|
final GuidedStepChallengeController controller;
|
|
final QuizQuestion question;
|
|
final Color accentColor;
|
|
final bool isGame;
|
|
final Color ink;
|
|
final Color muted;
|
|
final String expectedLabel;
|
|
final bool hasAnswer;
|
|
final bool checked;
|
|
final bool revealImmediately;
|
|
|
|
const _SimpleAnswerField({
|
|
Key? key,
|
|
required this.controller,
|
|
required this.question,
|
|
required this.accentColor,
|
|
required this.isGame,
|
|
required this.ink,
|
|
required this.muted,
|
|
required this.expectedLabel,
|
|
required this.hasAnswer,
|
|
required this.checked,
|
|
required this.revealImmediately,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_SimpleAnswerField> createState() => _SimpleAnswerFieldState();
|
|
}
|
|
|
|
class _SimpleAnswerFieldState extends State<_SimpleAnswerField> {
|
|
late final TextEditingController _tec;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tec = TextEditingController(text: widget.controller.simpleText(widget.question.id));
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_SimpleAnswerField old) {
|
|
super.didUpdateWidget(old);
|
|
// Reset après un "Corriger ces réponses" (checked repasse à false et le
|
|
// texte stocké est vidé).
|
|
if (old.checked && !widget.checked) {
|
|
_tec.clear();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tec.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = widget.controller;
|
|
final correct = c._simpleCorrect(widget.question, _tec.text);
|
|
final empty = _tec.text.trim().isEmpty;
|
|
|
|
Color borderColor;
|
|
Color fillColor;
|
|
if (widget.hasAnswer) {
|
|
borderColor = correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343);
|
|
fillColor =
|
|
(correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343)).withOpacity(0.12);
|
|
} else {
|
|
borderColor = widget.isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2);
|
|
fillColor = widget.isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
enabled: !widget.checked,
|
|
controller: _tec,
|
|
onChanged: (v) {
|
|
c.setSimple(widget.question.id, v);
|
|
setState(() {});
|
|
},
|
|
style: TextStyle(color: widget.ink, fontSize: 15),
|
|
decoration: InputDecoration(
|
|
hintText: 'Votre réponse…',
|
|
filled: true,
|
|
fillColor: fillColor,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: borderColor, width: 1.5),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: borderColor, width: 1.5),
|
|
),
|
|
disabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: borderColor, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
if (!widget.checked) ...[
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: empty ? null : () => c.checkSimple(widget.question.id),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: widget.accentColor,
|
|
foregroundColor: widget.isGame ? const Color(0xFF1a1305) : Colors.white,
|
|
disabledBackgroundColor: widget.accentColor.withOpacity(0.5),
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
elevation: 0,
|
|
),
|
|
child: const Text('Valider cette réponse',
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
],
|
|
if (widget.hasAnswer && !correct) ...[
|
|
const SizedBox(height: 8),
|
|
widget.revealImmediately
|
|
? Text.rich(
|
|
TextSpan(children: [
|
|
TextSpan(
|
|
text: 'La bonne réponse était : ',
|
|
style: TextStyle(fontSize: 13, color: widget.muted)),
|
|
TextSpan(
|
|
text: widget.expectedLabel,
|
|
style: TextStyle(
|
|
fontSize: 13, color: widget.muted, fontWeight: FontWeight.w700)),
|
|
]),
|
|
)
|
|
: const Text('Ce n\'est pas la bonne réponse, réessayez.',
|
|
style: TextStyle(
|
|
fontSize: 13, color: Color(0xFFD14343), fontWeight: FontWeight.w600)),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pavé digicode (boutons 0-9) pour une réponse "Simple" purement numérique —
|
|
/// même contrat que `_SimpleAnswerField` (stocke via `controller.setSimple`),
|
|
/// juste un rendu façon cadenas physique plutôt qu'un clavier texte.
|
|
class _DigicodeField extends StatefulWidget {
|
|
final GuidedStepChallengeController controller;
|
|
final QuizQuestion question;
|
|
final Color accentColor;
|
|
final bool isGame;
|
|
final Color ink;
|
|
final Color muted;
|
|
final int expectedLength;
|
|
final String expectedLabel;
|
|
final bool hasAnswer;
|
|
final bool checked;
|
|
final bool revealImmediately;
|
|
|
|
const _DigicodeField({
|
|
Key? key,
|
|
required this.controller,
|
|
required this.question,
|
|
required this.accentColor,
|
|
required this.isGame,
|
|
required this.ink,
|
|
required this.muted,
|
|
required this.expectedLength,
|
|
required this.expectedLabel,
|
|
required this.hasAnswer,
|
|
required this.checked,
|
|
required this.revealImmediately,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_DigicodeField> createState() => _DigicodeFieldState();
|
|
}
|
|
|
|
class _DigicodeFieldState extends State<_DigicodeField> {
|
|
late String _entered;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_entered = widget.controller.simpleText(widget.question.id);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_DigicodeField old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.checked && !widget.checked) {
|
|
_entered = '';
|
|
}
|
|
}
|
|
|
|
void _press(String key) {
|
|
if (widget.checked) return;
|
|
setState(() {
|
|
if (key == 'back') {
|
|
if (_entered.isNotEmpty) {
|
|
_entered = _entered.substring(0, _entered.length - 1);
|
|
}
|
|
} else if (_entered.length < widget.expectedLength) {
|
|
_entered += key;
|
|
}
|
|
widget.controller.setSimple(widget.question.id, _entered);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = widget.controller;
|
|
final correct = c._simpleCorrect(widget.question, _entered);
|
|
final filled = _entered.length == widget.expectedLength;
|
|
|
|
Color boxBorder;
|
|
Color boxBg;
|
|
if (widget.hasAnswer) {
|
|
boxBorder = correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343);
|
|
boxBg = (correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343))
|
|
.withOpacity(0.12);
|
|
} else {
|
|
boxBorder = widget.isGame ? Colors.white.withOpacity(0.15) : const Color(0xFFE2DCD2);
|
|
boxBg = widget.isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(widget.expectedLength, (i) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
width: 40,
|
|
height: 48,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: boxBorder, width: 1.5),
|
|
color: boxBg,
|
|
),
|
|
child: Text(
|
|
i < _entered.length ? _entered[i] : '',
|
|
style: TextStyle(
|
|
color: widget.ink, fontSize: 22, fontWeight: FontWeight.w800),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_numpad(),
|
|
if (!widget.checked) ...[
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: filled ? () => c.checkSimple(widget.question.id) : null,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: widget.accentColor,
|
|
foregroundColor: widget.isGame ? const Color(0xFF1a1305) : Colors.white,
|
|
disabledBackgroundColor: widget.accentColor.withOpacity(0.5),
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
elevation: 0,
|
|
),
|
|
child: const Text('Valider ce code',
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
],
|
|
if (widget.hasAnswer && !correct) ...[
|
|
const SizedBox(height: 8),
|
|
widget.revealImmediately
|
|
? Text.rich(
|
|
TextSpan(children: [
|
|
TextSpan(
|
|
text: 'Le bon code était : ',
|
|
style: TextStyle(fontSize: 13, color: widget.muted)),
|
|
TextSpan(
|
|
text: widget.expectedLabel,
|
|
style: TextStyle(
|
|
fontSize: 13, color: widget.muted, fontWeight: FontWeight.w700)),
|
|
]),
|
|
)
|
|
: const Text('Ce n\'est pas le bon code, réessayez.',
|
|
style: TextStyle(
|
|
fontSize: 13, color: Color(0xFFD14343), fontWeight: FontWeight.w600)),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _numpad() {
|
|
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'back', '0', ''];
|
|
return Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: keys.map((key) {
|
|
if (key.isEmpty) return const SizedBox(width: 56, height: 48);
|
|
final isBack = key == 'back';
|
|
return SizedBox(
|
|
width: 56,
|
|
height: 48,
|
|
child: ElevatedButton(
|
|
onPressed: widget.checked ? null : () => _press(key),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor:
|
|
widget.isGame ? Colors.white.withOpacity(0.06) : const Color(0xFFF0EBE3),
|
|
foregroundColor: widget.ink,
|
|
padding: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
elevation: 0,
|
|
),
|
|
child: isBack
|
|
? const Icon(Icons.backspace_outlined, size: 18)
|
|
: Text(key, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
}
|