From 55d96c3bafc9f0ce028da2597bf5c81185274cad Mon Sep 17 00:00:00 2001 From: akhdanre Date: Sun, 4 May 2025 21:53:20 +0700 Subject: [PATCH] fix: game play on quiz --- .../controller/quiz_play_controller.dart | 60 ++++--- .../quiz_play/view/quiz_play_view.dart | 77 ++++---- .../controller/quiz_preview_controller.dart | 2 +- .../controller/quiz_result_controller.dart | 64 ++----- .../component/quiz_item_wa_component.dart | 167 ++++++++++++++++++ .../quiz_result/view/quiz_result_view.dart | 80 ++++++--- 6 files changed, 316 insertions(+), 134 deletions(-) create mode 100644 lib/feature/quiz_result/view/component/quiz_item_wa_component.dart diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart index ed5bd01..b1969ec 100644 --- a/lib/feature/quiz_play/controller/quiz_play_controller.dart +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -6,6 +6,9 @@ import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; +import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; class QuizPlayController extends GetxController { late final QuizData quizData; @@ -88,25 +91,35 @@ class QuizPlayController extends GetxController { void _submitAnswerIfNeeded() { final question = currentQuestion; - String userAnswer = ''; + dynamic userAnswer = ''; - switch (question.type) { - case 'fill_the_blank': - userAnswer = answerTextController.text.trim(); - break; - case 'option': - case 'true_false': - userAnswer = selectedAnswer.value.trim(); - break; + if (question is FillInTheBlankQuestion) { + userAnswer = answerTextController.text.toString(); + } else { + userAnswer = selectedAnswer.value; } - // answeredQuestions.add(AnsweredQuestion( - // index: currentIndex.value, - // questionIndex: question.index, - // selectedAnswer: userAnswer, - // correctAnswer: question., - // isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(), - // duration: currentQuestion.duration - timeLeft.value, - // )); + + dynamic correctAnswer; + if (question is FillInTheBlankQuestion) { + correctAnswer = question.targetAnswer.toLowerCase(); + userAnswer = userAnswer.toString().toLowerCase(); + } else if (question is TrueFalseQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = userAnswer == 'true' || userAnswer == true; + } else if (question is OptionQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = int.tryParse(userAnswer.toString()); + } + + final isCorrect = userAnswer == correctAnswer; + + answeredQuestions.add(AnsweredQuestion( + index: currentIndex.value, + questionIndex: question.index, + selectedAnswer: userAnswer, + isCorrect: isCorrect, + duration: question.duration - timeLeft.value, + )); } void nextQuestion() { @@ -138,10 +151,17 @@ class QuizPlayController extends GetxController { _timer?.cancel(); AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); + await Future.delayed(Duration(seconds: 2)); + + print(quizData); + Get.offAllNamed( AppRoutes.resultQuizPage, - arguments: [quizData, answeredQuestions], + arguments: { + "quiz_data": quizData, + "answer_data": answeredQuestions, + }, ); } @@ -157,7 +177,6 @@ class AnsweredQuestion { final int index; final int questionIndex; final dynamic selectedAnswer; - final dynamic correctAnswer; final bool isCorrect; final int duration; @@ -165,7 +184,6 @@ class AnsweredQuestion { required this.index, required this.questionIndex, required this.selectedAnswer, - required this.correctAnswer, required this.isCorrect, required this.duration, }); @@ -175,7 +193,6 @@ class AnsweredQuestion { index: json['index'], questionIndex: json['question_index'], selectedAnswer: json['selectedAnswer'], - correctAnswer: json['correctAnswer'], isCorrect: json['isCorrect'], duration: json['duration'], ); @@ -185,7 +202,6 @@ class AnsweredQuestion { 'index': index, 'question_index': questionIndex, 'selectedAnswer': selectedAnswer, - 'correctAnswer': correctAnswer, 'isCorrect': isCorrect, 'duration': duration, }; diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart index 1006f80..5fc9989 100644 --- a/lib/feature/quiz_play/view/quiz_play_view.dart +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizPlayView extends GetView { @@ -33,7 +36,7 @@ class QuizPlayView extends GetView { const SizedBox(height: 12), _buildQuestionText(), const SizedBox(height: 30), - // _buildAnswerSection(), + _buildAnswerSection(), const Spacer(), _buildNextButton(), ], @@ -98,44 +101,44 @@ class QuizPlayView extends GetView { ); } - // Widget _buildAnswerSection() { - // final question = controller.currentQuestion; + Widget _buildAnswerSection() { + final question = controller.currentQuestion; - // if (question.type == 'option' && question.options != null) { - // return Column( - // children: List.generate(question.options!.length, (index) { - // final option = question.options![index]; - // final isSelected = controller.idxOptionSelected.value == index; + if (question is OptionQuestion) { + return Column( + children: List.generate(question.options.length, (index) { + final option = question.options[index]; + final isSelected = controller.idxOptionSelected.value == index; - // return Container( - // margin: const EdgeInsets.only(bottom: 12), - // width: double.infinity, - // child: ElevatedButton( - // style: ElevatedButton.styleFrom( - // backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, - // foregroundColor: isSelected ? Colors.white : Colors.black, - // side: const BorderSide(color: Colors.grey), - // padding: const EdgeInsets.symmetric(vertical: 14), - // shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - // ), - // onPressed: () => controller.selectAnswerOption(index), - // child: Text(option), - // ), - // ); - // }), - // ); - // } else if (question.type == 'true_false') { - // return Row( - // mainAxisAlignment: MainAxisAlignment.spaceEvenly, - // children: [ - // _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), - // _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), - // ], - // ); - // } else { - // return GlobalTextField(controller: controller.answerTextController); - // } - // } + return Container( + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? AppColors.primaryBlue : Colors.white, + foregroundColor: isSelected ? Colors.white : Colors.black, + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => controller.selectAnswerOption(index), + child: Text(option), + ), + ); + }), + ); + } else if (question.type == 'true_false') { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF), + _buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF), + ], + ); + } else { + return GlobalTextField(controller: controller.answerTextController); + } + } Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer) { return Obx(() { diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart index 03d87d4..66d0da7 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -32,7 +32,7 @@ class QuizPreviewController extends GetxController { if (Get.arguments is List) { data = Get.arguments as List; } else { - data = []; // Default aman supaya gak crash + data = []; Get.snackbar('Error', 'Data soal tidak ditemukan'); } } diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart index 5bf445a..c436df3 100644 --- a/lib/feature/quiz_result/controller/quiz_result_controller.dart +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -1,14 +1,12 @@ import 'package:get/get.dart'; -import 'package:quiz_app/app/const/enums/question_type.dart'; import 'package:quiz_app/app/routes/app_pages.dart'; import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; -import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; -import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; class QuizResultController extends GetxController { late final QuizData question; - late final List questions; + late final List questions; late final List answers; RxInt correctAnswers = 0.obs; @@ -23,11 +21,12 @@ class QuizResultController extends GetxController { } void loadData() { - final args = Get.arguments as List; + final args = Get.arguments; - question = args[0] as QuizData; - // questions = question.questionListings; - answers = args[1] as List; + question = args['quiz_data'] as QuizData; + answers = args["answer_data"] as List; + + questions = question.questionListings; totalQuestions.value = questions.length; } @@ -40,52 +39,9 @@ class QuizResultController extends GetxController { } String getResultMessage() { - return "Nilai kamu ${scorePercentage.value}"; - } - - QuestionData mapQuestionListingToQuestionData(QuestionListing questionListing, int index) { - // Convert type string ke enum - QuestionType? questionType; - switch (questionListing.type) { - case 'fill_the_blank': - questionType = QuestionType.fillTheBlank; - break; - case 'option': - questionType = QuestionType.option; - break; - case 'true_false': - questionType = QuestionType.trueOrFalse; - break; - default: - questionType = null; - } - - // Convert options ke OptionData - List? optionDataList; - if (questionListing.options != null) { - optionDataList = []; - for (int i = 0; i < questionListing.options!.length; i++) { - optionDataList.add(OptionData(index: i, text: questionListing.options![i])); - } - } - - // Cari correctAnswerIndex kalau tipe-nya option - int? correctAnswerIndex; - if (questionType == QuestionType.option && optionDataList != null) { - correctAnswerIndex = optionDataList.indexWhere((option) => option.text == questionListing.targetAnswer); - if (correctAnswerIndex == -1) { - correctAnswerIndex = null; // Kalau tidak ketemu - } - } - - return QuestionData( - index: index, - question: questionListing.question, - answer: questionListing.targetAnswer, - options: optionDataList, - correctAnswerIndex: correctAnswerIndex, - type: questionType, - ); + double value = scorePercentage.value; + String formatted = value % 1 == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + return "Nilai kamu $formatted"; } void onPopInvoke(bool isPop, dynamic value) { diff --git a/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart b/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart new file mode 100644 index 0000000..e6c92ed --- /dev/null +++ b/lib/feature/quiz_result/view/component/quiz_item_wa_component.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +class QuizItemWAComponent extends StatelessWidget { + final int index; + final String question; + final String type; + final dynamic userAnswer; + final dynamic targetAnswer; + final bool isCorrect; + final double timeSpent; + final List? options; + + const QuizItemWAComponent({ + super.key, + required this.index, + required this.question, + required this.type, + required this.userAnswer, + required this.targetAnswer, + required this.isCorrect, + required this.timeSpent, + this.options, + }); + + bool get isOptionType => type == 'option'; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$index. $question', + style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + if (isOptionType && options != null) _buildOptions(), + const SizedBox(height: 12), + _buildAnswerIndicator(), + const SizedBox(height: 16), + const Divider(height: 24, color: AppColors.shadowPrimary), + _buildMetadata(), + ], + ), + ); + } + + Widget _buildOptions() { + return Column( + children: options!.asMap().entries.map((entry) { + final int optIndex = entry.key; + final String text = entry.value; + + final bool isCorrectAnswer = optIndex == targetAnswer; + final bool isUserWrongAnswer = optIndex == userAnswer && !isCorrectAnswer; + + Color? backgroundColor; + IconData icon = LucideIcons.circle; + Color iconColor = AppColors.shadowPrimary; + + if (isCorrectAnswer) { + backgroundColor = AppColors.primaryBlue.withOpacity(0.15); + icon = LucideIcons.checkCircle2; + iconColor = AppColors.primaryBlue; + } else if (isUserWrongAnswer) { + backgroundColor = Colors.red.withOpacity(0.15); + icon = LucideIcons.xCircle; + iconColor = Colors.red; + } + + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.shadowPrimary), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Flexible( + child: Text(text, style: AppTextStyles.optionText), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildAnswerIndicator() { + final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; + final color = isCorrect ? AppColors.primaryBlue : Colors.red; + + final String userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString(); + final String correctAnswerText = targetAnswer.toString(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + 'Jawabanmu: $userAnswerText', + style: AppTextStyles.statValue, + ), + ], + ), + if (!isCorrect && !isOptionType) ...[ + const SizedBox(height: 6), + Row( + children: [ + const SizedBox(width: 26), + Text( + 'Jawaban benar: $correctAnswerText', + style: AppTextStyles.caption, + ), + ], + ), + ], + ], + ); + } + + Widget _buildMetadata() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _metaItem(icon: LucideIcons.helpCircle, label: type), + _metaItem(icon: LucideIcons.clock3, label: '${timeSpent.toStringAsFixed(1)}s'), + ], + ); + } + + Widget _metaItem({required IconData icon, required String label}) { + return Row( + children: [ + Icon(icon, size: 16, color: AppColors.primaryBlue), + const SizedBox(width: 6), + Text(label, style: AppTextStyles.caption), + ], + ); + } +} diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart index e15f974..5de6962 100644 --- a/lib/feature/quiz_result/view/quiz_result_view.dart +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart'; -import 'package:quiz_app/component/widget/question_container_widget.dart'; +import 'package:quiz_app/data/models/quiz/question/fill_in_the_blank_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/option_question_model.dart'; +import 'package:quiz_app/data/models/quiz/question/true_false_question_model.dart'; import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; +import 'package:quiz_app/feature/quiz_result/view/component/quiz_item_wa_component.dart'; class QuizResultView extends GetView { const QuizResultView({super.key}); @@ -20,21 +24,11 @@ class QuizResultView extends GetView { child: Obx(() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCustomAppBar(context), + _buildAppBar(context), const SizedBox(height: 16), _buildScoreSummary(), const SizedBox(height: 16), - Expanded( - child: ListView.builder( - itemCount: controller.questions.length, - itemBuilder: (context, index) { - return QuestionContainerWidget( - question: controller.mapQuestionListingToQuestionData(controller.questions[index], index), - answeredQuestion: controller.answers[index], - ); - }, - ), - ), + _buildQuizList(), ], )), ), @@ -43,14 +37,12 @@ class QuizResultView extends GetView { ); } - Widget _buildCustomAppBar(BuildContext context) { + Widget _buildAppBar(BuildContext context) { return Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), - onPressed: () { - controller.onPopInvoke(true, null); - }, + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black), + onPressed: () => controller.onPopInvoke(true, null), ), const SizedBox(width: 8), const Text( @@ -62,6 +54,7 @@ class QuizResultView extends GetView { } Widget _buildScoreSummary() { + final score = controller.scorePercentage.value; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -71,7 +64,7 @@ class QuizResultView extends GetView { ), const SizedBox(height: 8), LinearProgressIndicator( - value: controller.scorePercentage.value / 100, + value: score / 100, minHeight: 10, backgroundColor: AppColors.borderLight, valueColor: const AlwaysStoppedAnimation(AppColors.primaryBlue), @@ -81,10 +74,57 @@ class QuizResultView extends GetView { controller.getResultMessage(), style: TextStyle( fontSize: 16, - color: controller.scorePercentage.value >= 80 ? Colors.green : Colors.red, + color: score >= 80 ? Colors.green : Colors.red, ), ), ], ); } + + Widget _buildQuizList() { + return Expanded( + child: ListView.builder( + itemCount: controller.questions.length, + itemBuilder: (context, index) { + final answer = controller.answers[index]; + final question = controller.questions.firstWhere( + (q) => q.index == answer.questionIndex, + orElse: () => throw Exception("Question not found"), + ); + + final parsed = _parseAnswer(question, answer.selectedAnswer); + + return QuizItemWAComponent( + index: index, + isCorrect: answer.isCorrect, + question: question.question, + targetAnswer: parsed.targetAnswer, + userAnswer: parsed.userAnswer, + timeSpent: answer.duration.toDouble(), + type: question.type, + options: parsed.options, + ); + }, + ), + ); + } + + /// Helper class for parsed answer details + ({dynamic userAnswer, dynamic targetAnswer, List options}) _parseAnswer(dynamic question, dynamic selectedAnswer) { + switch (question.type) { + case 'fill_the_blank': + final q = question as FillInTheBlankQuestion; + return (userAnswer: selectedAnswer.toString(), targetAnswer: q.targetAnswer, options: []); + case 'option': + final q = question as OptionQuestion; + final parsedAnswer = int.tryParse(selectedAnswer.toString()) ?? -1; + return (userAnswer: parsedAnswer, targetAnswer: q.targetAnswer, options: q.options); + case 'true_false': + final q = question as TrueFalseQuestion; + final boolAnswer = selectedAnswer is bool ? selectedAnswer : selectedAnswer.toString().toLowerCase() == 'true'; + return (userAnswer: boolAnswer, targetAnswer: q.targetAnswer, options: []); + default: + throw Exception("Unknown question type: ${question.type}"); + } + } }