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:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Components/check_input_container.dart'; import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/number_input_container.dart'; import 'package:manager_app/Components/resource_input_container.dart'; // `ResponseDTO.label` est en TranslationAndResourceDTO alors que // MultiStringInputContainer parle TranslationDTO : la ressource n'a pas de sens // sur une réponse de quiz, seul le texte circule. List _toTranslationList(List? list) => (list ?? []) .map((t) => TranslationDTO(language: t.language, value: t.value)) .toList(); List _fromTranslationList( List list) => list .map((t) => TranslationAndResourceDTO(language: t.language, value: t.value)) .toList(); ResponseDTO _emptyResponse({bool isGood = false, int order = 0}) => ResponseDTO(label: [], isGood: isGood, order: order); /// Une question neuve, encore sans identifiant serveur (`id: 0`) : c'est le /// backend qui l'attribue quand l'étape qui la porte est enregistrée. QuizQuestion newQuizQuestion({required String stepId, required int order}) => QuizQuestion( id: 0, label: [TranslationAndResourceDTO(language: 'FR', value: '')], responses: [], validationQuestionType: QuestionType.simple, order: order, guidedStepId: stepId, ); String questionTypeLabel(QuestionType? type) { switch (type) { case QuestionType.multipleChoice: return "QCM"; case QuestionType.puzzle: return "Puzzle"; default: return "Réponse attendue"; } } String questionPreview(BuildContext context, QuizQuestion question, int index) { if (question.label.isEmpty) return "Question ${index + 1}"; final fr = question.label.firstWhere( (t) => t.language == 'FR', orElse: () => question.label.first, ); final value = fr.value ?? ""; return value.trim().isEmpty ? "Question ${index + 1}" : value; } /// Les champs d'une question, sans son contenant : ils s'affichent dépliés dans /// le panneau de l'étape, plus dans une fenêtre à part. class QuestionFields extends StatelessWidget { const QuestionFields({ Key? key, required this.question, required this.onChanged, }) : super(key: key); final QuizQuestion question; final VoidCallback onChanged; /// Le type « réponse attendue » se joue sur une réponse unique, toujours /// marquée bonne. Elle est créée à la volée si elle manque. void _ensureSimpleResponse() { if (question.responses.isEmpty) { question.responses.add(_emptyResponse(isGood: true, order: 0)); } question.responses[0].isGood = true; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MultiStringInputContainer( label: l10n.questionAskedLabel, modalLabel: l10n.questionTitleLabel, initialValue: _toTranslationList(question.label), onGetResult: (val) { question.label = _fromTranslationList(val); onChanged(); }, maxLines: 3, isTitle: false, isHTML: true, showPreview: true, ), SizedBox(height: kSpace4), Text("Type de validation :", style: kLabelField), SizedBox(height: kSpace2), DropdownButton( value: question.validationQuestionType, items: [ DropdownMenuItem( value: QuestionType.simple, child: Text("Simple (texte attendu)")), DropdownMenuItem( value: QuestionType.multipleChoice, child: Text("Choix multiples (QCM)")), DropdownMenuItem(value: QuestionType.puzzle, child: Text("Puzzle")), ], onChanged: (val) { question.validationQuestionType = val; question.responses = []; onChanged(); }, ), if (question.validationQuestionType == QuestionType.simple) ..._simpleFields(context, l10n), if (question.validationQuestionType == QuestionType.multipleChoice) ..._multipleChoiceFields(context, l10n), if (question.validationQuestionType == QuestionType.puzzle) ..._puzzleFields(context), ], ); } List _simpleFields(BuildContext context, AppLocalizations l10n) => [ Divider(height: kSpace7, color: kLineSoft), Text(l10n.expectedAnswerLabel, style: kLabelField), SizedBox(height: kSpace3), Builder(builder: (_) { _ensureSimpleResponse(); return MultiStringInputContainer( label: "", modalLabel: l10n.expectedAnswerModalLabel, initialValue: _toTranslationList(question.responses[0].label), onGetResult: (val) { question.responses[0].label = _fromTranslationList(val); question.responses[0].isGood = true; onChanged(); }, maxLines: 1, isTitle: true, isHTML: true, showPreview: true, ); }), SizedBox(height: kSpace1), Text(l10n.validationNote, style: kTextHint.copyWith(fontStyle: FontStyle.italic)), SizedBox(height: kSpace3), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: kSurface2, borderRadius: BorderRadius.circular(kRadiusCard), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.dialpad, size: 18, color: kPrimaryColor), SizedBox(width: kSpace3), Expanded( child: Text( "Si la réponse attendue ne contient que des chiffres, le visiteur " "voit automatiquement un pavé numérique façon cadenas (digicode) " "au lieu du champ texte. Rien à configurer.", style: kTextHint, ), ), ], ), ), ]; List _multipleChoiceFields( BuildContext context, AppLocalizations l10n) => [ Divider(height: kSpace7, color: kLineSoft), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.possibleAnswersLabel, style: kLabelField), TextButton.icon( icon: Icon(Icons.add_circle_outline, color: kSuccess), label: Text("Ajouter", style: TextStyle(color: kSuccess)), onPressed: () { question.responses .add(_emptyResponse(order: question.responses.length)); onChanged(); }, ), ], ), if (question.responses.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: kSpace3), child: Text(l10n.noAnswerDefined, style: kTextHint.copyWith(fontStyle: FontStyle.italic)), ) else Column( children: List.generate(question.responses.length, (i) { final resp = question.responses[i]; return Card( margin: const EdgeInsets.only(bottom: kSpace3), child: Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace3, vertical: kSpace1), child: Row( children: [ Tooltip( message: resp.isGood == true ? l10n.correctAnswer : l10n.wrongAnswer, child: Checkbox( value: resp.isGood ?? false, activeColor: kSuccess, onChanged: (val) { resp.isGood = val; onChanged(); }, ), ), Expanded( child: MultiStringInputContainer( label: "${l10n.answerLabel} ${i + 1} :", modalLabel: "${l10n.answerLabel} ${i + 1}", initialValue: _toTranslationList(resp.label), onGetResult: (val) { resp.label = _fromTranslationList(val); onChanged(); }, maxLines: 1, isTitle: true, isHTML: true, showPreview: true, ), ), IconButton( icon: Icon(Icons.delete_outline, color: kError, size: 20), onPressed: () { question.responses.removeAt(i); for (var j = 0; j < question.responses.length; j++) { question.responses[j].order = j; } onChanged(); }, ), ], ), ), ); }), ), SizedBox(height: kSpace1), Text(l10n.answerNote, style: kTextHint.copyWith(fontStyle: FontStyle.italic)), ]; List _puzzleFields(BuildContext context) => [ Divider(height: kSpace7, color: kLineSoft), Text("Configuration du Puzzle", style: kLabelField), SizedBox(height: kSpace4), ResourceInputContainer( label: "Image du puzzle :", initialValue: question.puzzleImageId, onChanged: (res) { question.puzzleImageId = res.id; question.puzzleImage = Resource.fromJson(res.toJson()); onChanged(); }, ), SizedBox(height: kSpace4), Row( children: [ Expanded( child: NumberInputContainer( label: "Lignes :", initialValue: question.puzzleRows ?? 3, onChanged: (val) { question.puzzleRows = int.tryParse(val) ?? 3; onChanged(); }, isSmall: true, ), ), SizedBox(width: kSpace7), Expanded( child: NumberInputContainer( label: "Colonnes :", initialValue: question.puzzleCols ?? 3, onChanged: (val) { question.puzzleCols = int.tryParse(val) ?? 3; onChanged(); }, isSmall: true, ), ), ], ), SizedBox(height: kSpace3), CheckInputContainer( label: "Puzzle glissant (Sliding) :", isChecked: question.isSlidingPuzzle ?? false, onChanged: (val) { question.isSlidingPuzzle = val; onChanged(); }, ), ]; } /// La liste des questions d'une étape : chaque ligne se déplie sur place au /// lieu d'ouvrir une fenêtre — c'est ce qui ramène la profondeur à 2. class QuestionsBlock extends StatefulWidget { const QuestionsBlock({ Key? key, required this.questions, required this.stepId, required this.onChanged, }) : super(key: key); final List questions; final String stepId; final VoidCallback onChanged; @override State createState() => _QuestionsBlockState(); } class _QuestionsBlockState extends State { QuizQuestion? _expanded; void _add() { final question = newQuizQuestion( stepId: widget.stepId, order: widget.questions.length, ); setState(() { widget.questions.add(question); _expanded = question; }); widget.onChanged(); } void _delete(QuizQuestion question) { showConfirmationDialog( "Supprimer cette question ?", () {}, () { setState(() { widget.questions.remove(question); for (var i = 0; i < widget.questions.length; i++) { widget.questions[i].order = i; } if (_expanded == question) _expanded = null; }); widget.onChanged(); }, context, ); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.questions.isEmpty) Padding( padding: const EdgeInsets.only(bottom: kSpace3), child: Text(AppLocalizations.of(context)!.noQuestionsConfigured, style: kTextHint.copyWith(fontStyle: FontStyle.italic)), ) else for (var i = 0; i < widget.questions.length; i++) _row(context, i, widget.questions[i]), SizedBox(height: kSpace3), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( icon: Icon(Icons.add, size: 18, color: kPrimaryColor), label: Text(AppLocalizations.of(context)!.addQuestion, style: TextStyle(color: kPrimaryColor)), style: OutlinedButton.styleFrom( side: BorderSide(color: kLine), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(kRadiusCard)), ), onPressed: _add, ), ), ], ); } Widget _row(BuildContext context, int index, QuizQuestion question) { final isOpen = _expanded == question; return Container( margin: const EdgeInsets.only(bottom: kSpace2), decoration: BoxDecoration( color: isOpen ? kSurface2 : kSurface, border: Border.all(color: isOpen ? kPrimaryColor : kLineSoft), borderRadius: BorderRadius.circular(kRadiusCard), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( onTap: () => setState(() => _expanded = isOpen ? null : question), child: Padding( padding: const EdgeInsets.fromLTRB(11, 9, 5, 9), child: Row( children: [ SizedBox( width: 24, child: Text( (index + 1).toString().padLeft(2, '0'), style: kOverlineMono, ), ), Expanded( child: HtmlWidget( questionPreview(context, question, index), textStyle: kTextBody, ), ), SizedBox(width: kSpace3), Text(questionTypeLabel(question.validationQuestionType), style: kTextHint), IconButton( icon: Icon(Icons.delete_outline, size: 18, color: kError), tooltip: "Supprimer", onPressed: () => _delete(question), ), Icon(isOpen ? Icons.expand_less : Icons.expand_more, size: 20, color: kInk3), ], ), ), ), if (isOpen) Padding( padding: const EdgeInsets.fromLTRB(11, 0, 11, 13), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Divider(height: kSpace4, color: kLine), QuestionFields( key: ObjectKey(question), question: question, onChanged: () { setState(() {}); widget.onChanged(); }, ), ], ), ), ], ), ); } }