fix: interface and logic on the result

This commit is contained in:
akhdanre 2025-04-30 14:14:11 +07:00
parent 92f349e8ba
commit aa6b35f422
5 changed files with 392 additions and 237 deletions

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -2,6 +2,44 @@ import 'package:flutter/material.dart';
import 'package:quiz_app/app/const/colors/app_colors.dart';
class AppDialog {
static Future<void> showMessage(BuildContext context, String message) async {
await showDialog(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return Dialog(
backgroundColor: AppColors.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.info_outline,
size: 40,
color: AppColors.primaryBlue,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: AppColors.darkText,
),
textAlign: TextAlign.center,
),
],
),
),
);
},
);
}
static Future<void> showExitConfirmationDialog(BuildContext context) async {
await showDialog(
context: context,

View File

@ -8,7 +8,11 @@ class QuestionContainerWidget extends StatelessWidget {
final QuestionData question;
final AnsweredQuestion? answeredQuestion;
const QuestionContainerWidget({super.key, required this.question, this.answeredQuestion});
const QuestionContainerWidget({
super.key,
required this.question,
this.answeredQuestion,
});
@override
Widget build(BuildContext context) {
@ -16,54 +20,79 @@ class QuestionContainerWidget extends StatelessWidget {
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(2, 2),
),
],
),
decoration: _containerDecoration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Soal ${question.index}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.darkText)),
_buildTitle(),
const SizedBox(height: 6),
Text(
_mapQuestionTypeToText(question.type),
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText, fontStyle: FontStyle.italic),
),
_buildTypeLabel(),
const SizedBox(height: 12),
Text(
question.question ?? '-',
style: const TextStyle(fontSize: 16, color: AppColors.darkText),
),
_buildQuestionText(),
const SizedBox(height: 16),
_buildAnswerSection(question),
_buildAnswerSection(),
if (answeredQuestion != null) ...[
const SizedBox(height: 16),
_buildAnsweredSection(answeredQuestion!),
_buildAnsweredSection(question, answeredQuestion!),
],
const SizedBox(height: 10),
Text(
'Durasi: ${question.duration} detik',
style: TextStyle(fontSize: 14, color: AppColors.softGrayText),
),
_buildDurationInfo(),
],
),
);
}
Widget _buildAnswerSection(QuestionData question) {
if (question.type == QuestionType.option && question.options != null) {
// --- UI Builders ---
Widget _buildTitle() => Text(
'Soal ${question.index}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppColors.darkText,
),
);
Widget _buildTypeLabel() => Text(
_mapQuestionTypeToText(question.type),
style: const TextStyle(
fontSize: 12,
color: AppColors.softGrayText,
fontStyle: FontStyle.italic,
),
);
Widget _buildQuestionText() => Text(
question.question ?? '-',
style: const TextStyle(fontSize: 16, color: AppColors.darkText),
);
Widget _buildAnswerSection() {
switch (question.type) {
case QuestionType.option:
return _buildOptionAnswers();
case QuestionType.fillTheBlank:
return _buildFillInBlankAnswers();
case QuestionType.trueOrFalse:
return _buildTrueFalseAnswer();
default:
return const SizedBox();
}
}
Widget _buildOptionAnswers() {
final options = question.options ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: question.options!.map((option) {
bool isCorrect = question.correctAnswerIndex == option.index;
children: options.map((option) {
final isCorrect = option.index == question.correctAnswerIndex;
return _buildOptionItem(option.text, isCorrect);
}).toList(),
);
}
Widget _buildOptionItem(String text, bool isCorrect) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
@ -81,7 +110,7 @@ class QuestionContainerWidget extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
option.text,
text,
style: TextStyle(
fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal,
color: isCorrect ? AppColors.primaryBlue : AppColors.darkText,
@ -91,28 +120,32 @@ class QuestionContainerWidget extends StatelessWidget {
],
),
);
}).toList(),
);
} else if (question.type == QuestionType.fillTheBlank) {
}
Widget _buildFillInBlankAnswers() {
final variations = _generateFillBlankVariations(question.answer ?? '-');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildFillTheBlankPossibilities(question.answer ?? '-'),
children: variations.map((text) => _buildBulletText(text)).toList(),
);
} else if (question.type == QuestionType.trueOrFalse) {
}
Widget _buildTrueFalseAnswer() {
return Text(
'Jawaban: ${question.answer ?? '-'}',
style: const TextStyle(color: AppColors.softGrayText),
);
} else {
return const SizedBox();
}
}
Widget _buildAnsweredSection(AnsweredQuestion answered) {
Widget _buildAnsweredSection(QuestionData question, AnsweredQuestion answered) {
String answer = question.type == QuestionType.option ? question.options![int.parse(answered.selectedAnswer)].text : answered.selectedAnswer;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Jawaban Anda:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.darkText)),
const Text(
'Jawaban Anda:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.darkText),
),
const SizedBox(height: 6),
Row(
children: [
@ -124,7 +157,7 @@ class QuestionContainerWidget extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
answered.selectedAnswer,
answer,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -138,27 +171,7 @@ class QuestionContainerWidget extends StatelessWidget {
);
}
String _mapQuestionTypeToText(QuestionType? type) {
switch (type) {
case QuestionType.option:
return 'Tipe: Pilihan Ganda';
case QuestionType.fillTheBlank:
return 'Tipe: Isian Kosong';
case QuestionType.trueOrFalse:
return 'Tipe: Benar / Salah';
default:
return 'Tipe: Tidak diketahui';
}
}
List<Widget> _buildFillTheBlankPossibilities(String answer) {
List<String> possibilities = [
_capitalizeEachWord(answer),
answer.toLowerCase(),
_capitalizeFirstWordOnly(answer),
];
return possibilities.map((option) {
Widget _buildBulletText(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
@ -166,29 +179,67 @@ class QuestionContainerWidget extends StatelessWidget {
const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText),
const SizedBox(width: 6),
Text(
option,
text,
style: const TextStyle(color: AppColors.darkText),
),
],
),
);
}).toList();
}
Widget _buildDurationInfo() {
String duration = question.duration.toString();
if (answeredQuestion != null) duration = answeredQuestion!.duration.toString();
return Text(
'Durasi: $duration detik',
style: const TextStyle(fontSize: 14, color: AppColors.softGrayText),
);
}
// --- Utils ---
List<String> _generateFillBlankVariations(String answer) {
return [
_capitalizeEachWord(answer),
answer.toLowerCase(),
_capitalizeFirstWordOnly(answer),
];
}
String _capitalizeEachWord(String text) {
return text.split(' ').map((word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}).join(' ');
return text.split(' ').map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}' : '').join(' ');
}
String _capitalizeFirstWordOnly(String text) {
if (text.isEmpty) return text;
List<String> parts = text.split(' ');
parts[0] = parts[0][0].toUpperCase() + parts[0].substring(1).toLowerCase();
final parts = text.split(' ');
if (parts.isEmpty) return text;
parts[0] = _capitalizeEachWord(parts[0]);
for (int i = 1; i < parts.length; i++) {
parts[i] = parts[i].toLowerCase();
}
return parts.join(' ');
}
String _mapQuestionTypeToText(QuestionType? type) {
return switch (type) {
QuestionType.option => 'Tipe: Pilihan Ganda',
QuestionType.fillTheBlank => 'Tipe: Isian Kosong',
QuestionType.trueOrFalse => 'Tipe: Benar / Salah',
_ => 'Tipe: Tidak diketahui',
};
}
BoxDecoration get _containerDecoration => BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(2, 2),
),
],
);
}

View File

@ -3,22 +3,29 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
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_listings_model.dart';
class QuizPlayController extends GetxController {
late final QuizData quizData;
// State & UI
final currentIndex = 0.obs;
final timeLeft = 0.obs;
final isStarting = true.obs;
final isAnswerSelected = false.obs;
final prepareDuration = 3.obs;
// Answer-related
final selectedAnswer = ''.obs;
final idxOptionSelected = (-1).obs;
final choosenAnswerTOF = 0.obs;
final List<AnsweredQuestion> answeredQuestions = [];
// Input controller
final answerTextController = TextEditingController();
final choosenAnswerTOF = 0.obs;
Timer? _timer;
QuestionListing get currentQuestion => quizData.questionListings[currentIndex.value];
@ -29,16 +36,12 @@ class QuizPlayController extends GetxController {
quizData = Get.arguments as QuizData;
_startCountdown();
// Listener untuk fill the blank
// Listener untuk fill in the blank
answerTextController.addListener(() {
if (answerTextController.text.trim().isNotEmpty) {
isAnswerSelected.value = true;
} else {
isAnswerSelected.value = false;
}
isAnswerSelected.value = answerTextController.text.trim().isNotEmpty;
});
// Listener untuk pilihan true/false
// Listener untuk true/false
ever(choosenAnswerTOF, (value) {
if (value != 0) {
isAnswerSelected.value = true;
@ -47,14 +50,19 @@ class QuizPlayController extends GetxController {
}
void _startCountdown() async {
await Future.delayed(const Duration(seconds: 3));
isStarting.value = false;
for (int i = 3; i > 0; i--) {
prepareDuration.value = i;
await Future.delayed(const Duration(seconds: 1));
}
_startTimer();
isStarting.value = true;
}
void _startTimer() {
timeLeft.value = currentQuestion.duration;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (timeLeft.value > 0) {
timeLeft.value--;
@ -63,20 +71,16 @@ class QuizPlayController extends GetxController {
_nextQuestion();
}
});
isAnswerSelected.value = false;
}
void selectAnswerOption(int selectedIndex) {
selectedAnswer.value = selectedIndex.toString();
isAnswerSelected.value = true;
idxOptionSelected.value = selectedIndex;
isAnswerSelected.value = true;
}
// void selectAnswerFTB(String answer) {
// selectedAnswer.value = answer;
// isAnswerSelected.value = true;
// }
void onChooseTOF(bool value) {
choosenAnswerTOF.value = value ? 1 : 2;
selectedAnswer.value = value.toString();
@ -84,22 +88,24 @@ class QuizPlayController extends GetxController {
void _submitAnswerIfNeeded() {
final question = currentQuestion;
String userAnswer = "";
String userAnswer = '';
if (question.type == "fill_the_blank") {
switch (question.type) {
case 'fill_the_blank':
userAnswer = answerTextController.text.trim();
} else if (question.type == "option") {
userAnswer = selectedAnswer.value.trim();
} else if (question.type == "true_false") {
break;
case 'option':
case 'true_false':
userAnswer = selectedAnswer.value.trim();
break;
}
answeredQuestions.add(AnsweredQuestion(
index: currentIndex.value,
question: question.question,
questionIndex: question.index,
selectedAnswer: userAnswer,
correctAnswer: question.targetAnswer.trim(),
isCorrect: userAnswer.toLowerCase() == question.targetAnswer.trim().toLowerCase(),
duration: currentQuestion.duration - timeLeft.value,
));
}
@ -110,20 +116,28 @@ class QuizPlayController extends GetxController {
void _nextQuestion() {
_timer?.cancel();
if (currentIndex.value < quizData.questionListings.length - 1) {
currentIndex.value++;
answerTextController.clear();
selectedAnswer.value = '';
choosenAnswerTOF.value = 0;
_resetAnswerState();
_startTimer();
} else {
_finishQuiz();
}
}
void _finishQuiz() {
_timer?.cancel();
void _resetAnswerState() {
answerTextController.clear();
selectedAnswer.value = '';
choosenAnswerTOF.value = 0;
idxOptionSelected.value = -1;
isAnswerSelected.value = false;
}
void _finishQuiz() async {
_timer?.cancel();
AppDialog.showMessage(Get.context!, "Yeay semua soal selesai");
await Future.delayed(Duration(seconds: 2));
Get.offAllNamed(
AppRoutes.resultQuizPage,
arguments: [quizData, answeredQuestions],
@ -140,26 +154,27 @@ class QuizPlayController extends GetxController {
class AnsweredQuestion {
final int index;
final String question;
final int questionIndex;
final String selectedAnswer;
final String correctAnswer;
final bool isCorrect;
final int duration;
AnsweredQuestion({
required this.index,
required this.question,
required this.questionIndex,
required this.selectedAnswer,
required this.correctAnswer,
required this.isCorrect,
required this.duration,
});
Map<String, dynamic> toJson() {
return {
Map<String, dynamic> toJson() => {
'index': index,
'question': question,
'question_index': questionIndex,
'selectedAnswer': selectedAnswer,
'correctAnswer': correctAnswer,
'isCorrect': isCorrect,
'duration': duration,
};
}
}

View File

@ -11,99 +11,33 @@ class QuizPlayView extends GetView<QuizPlayController> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF9FAFB),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: false,
title: const Text(
'Kerjakan Soal',
style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
),
iconTheme: const IconThemeData(color: Colors.black),
centerTitle: true,
),
// appBar: _buildAppBar(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Obx(() {
final question = controller.currentQuestion;
if (!controller.isStarting.value) {
return Center(
child: Text(
"Ready in ${controller.prepareDuration}",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(
value: controller.timeLeft.value / question.duration,
minHeight: 8,
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF2563EB)),
),
_buildCustomAppBar(),
const SizedBox(height: 20),
Text(
'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
_buildProgressBar(),
const SizedBox(height: 20),
_buildQuestionIndicator(),
const SizedBox(height: 12),
Text(
question.question,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
_buildQuestionText(),
const SizedBox(height: 30),
// Jawaban Berdasarkan Tipe Soal
if (question.type == 'option' && question.options != null)
...List.generate(question.options!.length, (index) {
final option = question.options![index];
return Container(
margin: const EdgeInsets.only(bottom: 12),
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: controller.idxOptionSelected.value == index ? AppColors.primaryBlue : Colors.white,
foregroundColor: controller.idxOptionSelected.value == index ? 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')
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTrueFalseButton('Ya', true, controller.choosenAnswerTOF),
_buildTrueFalseButton('Tidak', false, controller.choosenAnswerTOF),
],
)
else
GlobalTextField(controller: controller.answerTextController),
_buildAnswerSection(),
const Spacer(),
Obx(() {
return ElevatedButton(
onPressed: controller.nextQuestion,
style: ElevatedButton.styleFrom(
backgroundColor: controller.isAnswerSelected.value ? const Color(0xFF2563EB) : Colors.grey,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Next',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
);
}),
_buildNextButton(),
],
);
}),
@ -112,9 +46,103 @@ class QuizPlayView extends GetView<QuizPlayController> {
);
}
Widget _buildCustomAppBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text(
'Kerjakan Soal',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
],
),
);
}
Widget _buildProgressBar() {
final question = controller.currentQuestion;
return LinearProgressIndicator(
value: controller.timeLeft.value / question.duration,
minHeight: 8,
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF2563EB)),
);
}
Widget _buildQuestionIndicator() {
return Text(
'Soal ${controller.currentIndex.value + 1} dari ${controller.quizData.questionListings.length}',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildQuestionText() {
return Text(
controller.currentQuestion.question,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black,
),
);
}
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;
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(() {
bool isSelected = (choosenAnswer.value == (value ? 1 : 2));
final isSelected = (choosenAnswer.value == (value ? 1 : 2));
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? (value ? Colors.green[100] : Colors.red[100]) : Colors.white,
@ -129,4 +157,24 @@ class QuizPlayView extends GetView<QuizPlayController> {
);
});
}
Widget _buildNextButton() {
return Obx(() {
final isEnabled = controller.isAnswerSelected.value;
return ElevatedButton(
onPressed: isEnabled ? controller.nextQuestion : null,
style: ElevatedButton.styleFrom(
backgroundColor: isEnabled ? const Color(0xFF2563EB) : Colors.grey,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text(
'Next',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
);
});
}
}