473 lines
16 KiB
Dart
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();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|