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 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 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 _mcq = {}; final Set _puzzle = {}; final Map _simple = {}; final Set _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? 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(), ); } }