473 lines
16 KiB
Dart

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<TranslationDTO> _toTranslationList(List<TranslationAndResourceDTO>? list) =>
(list ?? [])
.map((t) => TranslationDTO(language: t.language, value: t.value))
.toList();
List<TranslationAndResourceDTO> _fromTranslationList(
List<TranslationDTO> 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(AppLocalizations l10n, QuestionType? type) {
switch (type) {
case QuestionType.multipleChoice:
return l10n.questionTypeMultipleChoiceShort;
case QuestionType.puzzle:
return l10n.questionTypePuzzle;
default:
return l10n.expectedAnswerModalLabel;
}
}
String questionPreview(BuildContext context, QuizQuestion question, int index) {
final fallback = AppLocalizations.of(context)!.questionNumbered(index + 1);
if (question.label.isEmpty) return fallback;
final fr = question.label.firstWhere(
(t) => t.language == 'FR',
orElse: () => question.label.first,
);
final value = fr.value ?? "";
return value.trim().isEmpty ? fallback : 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(l10n.questionValidationTypeLabel, style: kLabelField),
SizedBox(height: kSpace2),
DropdownButton<QuestionType>(
value: question.validationQuestionType,
items: [
DropdownMenuItem(
value: QuestionType.simple,
child: Text(l10n.questionTypeSimple)),
DropdownMenuItem(
value: QuestionType.multipleChoice,
child: Text(l10n.questionTypeMultipleChoice)),
DropdownMenuItem(value: QuestionType.puzzle, child: Text(l10n.questionTypePuzzle)),
],
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, l10n),
],
);
}
List<Widget> _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(
l10n.questionDigicodeHint,
style: kTextHint,
),
),
],
),
),
];
List<Widget> _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(l10n.add, 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<Widget> _puzzleFields(BuildContext context, AppLocalizations l10n) => [
Divider(height: kSpace7, color: kLineSoft),
Text(l10n.puzzleConfigTitle, style: kLabelField),
SizedBox(height: kSpace4),
ResourceInputContainer(
label: l10n.puzzleImageLabel,
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: l10n.puzzleRowsLabel,
initialValue: question.puzzleRows ?? 3,
onChanged: (val) {
question.puzzleRows = int.tryParse(val) ?? 3;
onChanged();
},
isSmall: true,
),
),
SizedBox(width: kSpace7),
Expanded(
child: NumberInputContainer(
label: l10n.puzzleColsLabel,
initialValue: question.puzzleCols ?? 3,
onChanged: (val) {
question.puzzleCols = int.tryParse(val) ?? 3;
onChanged();
},
isSmall: true,
),
),
],
),
SizedBox(height: kSpace3),
CheckInputContainer(
label: l10n.puzzleSlidingLabel,
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<QuizQuestion> questions;
final String stepId;
final VoidCallback onChanged;
@override
State<QuestionsBlock> createState() => _QuestionsBlockState();
}
class _QuestionsBlockState extends State<QuestionsBlock> {
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(
AppLocalizations.of(context)!.questionDeleteConfirm,
() {},
() {
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(AppLocalizations.of(context)!, question.validationQuestionType),
style: kTextHint),
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: kError),
tooltip: AppLocalizations.of(context)!.delete,
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();
},
),
],
),
),
],
),
);
}
}