fix: interface on the quiz result

This commit is contained in:
akhdanre 2025-05-25 15:26:45 +07:00
parent a7f6ce8e7e
commit c84133a372
4 changed files with 192 additions and 61 deletions

View File

@ -47,12 +47,10 @@
"avg_score": "Average Score", "avg_score": "Average Score",
"history_detail_title": "Quiz Detail", "history_detail_title": "Quiz Detail",
"correct_answer": "Correct",
"score": "Score", "score": "Score",
"time_taken": "Time", "time_taken": "Time",
"duration_seconds": "{second}s", "duration_seconds": "{second}s",
"your_answer": "Your answer: {answer}",
"question_type_option": "Multiple Choice", "question_type_option": "Multiple Choice",
"question_type_fill": "Fill in the Blank", "question_type_fill": "Fill in the Blank",
"question_type_true_false": "True / False", "question_type_true_false": "True / False",
@ -105,5 +103,13 @@
"contact_us": "Contact Us", "contact_us": "Contact Us",
"about_app": "About App", "about_app": "About App",
"version": "Version", "version": "Version",
"close": "Close" "close": "Close",
"your_answer": "Your answer: {answer}",
"correct_answer": "Correct answer: {answer}",
"not_answered": "Not Answered",
"seconds_suffix": "s",
"quiz_type_option": "Multiple Choice",
"quiz_type_true_false": "True or False",
"quiz_type_fill_the_blank": "Fill in the Blank"
} }

View File

@ -47,12 +47,10 @@
"avg_score": "Skor Rata-rata", "avg_score": "Skor Rata-rata",
"history_detail_title": "Detail Kuis", "history_detail_title": "Detail Kuis",
"correct_answer": "Benar",
"score": "Skor", "score": "Skor",
"time_taken": "Waktu", "time_taken": "Waktu",
"duration_seconds": "{second} detik", "duration_seconds": "{second} detik",
"your_answer": "Jawaban kamu: {answer}",
"question_type_option": "Pilihan Ganda", "question_type_option": "Pilihan Ganda",
"question_type_fill": "Isian Kosong", "question_type_fill": "Isian Kosong",
"question_type_true_false": "Benar / Salah", "question_type_true_false": "Benar / Salah",
@ -105,5 +103,13 @@
"contact_us": "Hubungi Kami", "contact_us": "Hubungi Kami",
"about_app": "Tentang Aplikasi", "about_app": "Tentang Aplikasi",
"version": "Versi", "version": "Versi",
"close": "Tutup" "close": "Tutup",
"your_answer": "Jawabanmu: {answer}",
"correct_answer": "Jawaban benar: {answer}",
"not_answered": "Tidak Menjawab",
"seconds_suffix": "d",
"quiz_type_option": "Pilihan Ganda",
"quiz_type_true_false": "Benar atau Salah",
"quiz_type_fill_the_blank": "Isian Kosong"
} }

View File

@ -47,12 +47,10 @@
"avg_score": "Skor Purata", "avg_score": "Skor Purata",
"history_detail_title": "Butiran Kuiz", "history_detail_title": "Butiran Kuiz",
"correct_answer": "Betul",
"score": "Skor", "score": "Skor",
"time_taken": "Masa", "time_taken": "Masa",
"duration_seconds": "{second} saat", "duration_seconds": "{second} saat",
"your_answer": "Jawapan anda: {answer}",
"question_type_option": "Pilihan Berganda", "question_type_option": "Pilihan Berganda",
"question_type_fill": "Isi Tempat Kosong", "question_type_fill": "Isi Tempat Kosong",
"question_type_true_false": "Betul / Salah", "question_type_true_false": "Betul / Salah",
@ -102,5 +100,13 @@
"contact_us": "Hubungi Kami", "contact_us": "Hubungi Kami",
"about_app": "Mengenai Aplikasi", "about_app": "Mengenai Aplikasi",
"version": "Versi", "version": "Versi",
"close": "Tutup" "close": "Tutup",
"your_answer": "Jawapan anda: {answer}",
"correct_answer": "Jawapan betul: {answer}",
"not_answered": "Tidak Dijawab",
"seconds_suffix": "s",
"quiz_type_option": "Pilihan Jawapan",
"quiz_type_true_false": "Betul atau Salah",
"quiz_type_fill_the_blank": "Isi Tempat Kosong"
} }

View File

@ -4,16 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:quiz_app/app/const/colors/app_colors.dart'; import 'package:quiz_app/app/const/colors/app_colors.dart';
import 'package:quiz_app/app/const/text/text_style.dart'; import 'package:quiz_app/app/const/text/text_style.dart';
/// Single quiz result tile.
/// Shows the question, the user's answer, correctness, time spent, and peroption feedback.
///
/// * Text strings are fully localised via `easy_localization`.
/// * Long answers now wrap to the next line rather than overflowing.
/// * Option chips highlight both the correct answer and the user's incorrect choice (if any).
class QuizItemWAComponent extends StatelessWidget { 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<String>? options;
const QuizItemWAComponent({ const QuizItemWAComponent({
super.key, super.key,
required this.index, required this.index,
@ -26,20 +23,44 @@ class QuizItemWAComponent extends StatelessWidget {
this.options, this.options,
}); });
bool get isOptionType => type == 'option'; /// Onebased question index.
final int index;
/// The question text.
final String question;
/// Question type: `option`, `true_false`, or `fill_the_blank`.
final String type;
/// Raw user answer (index, bool or string). `-1`/`null` means no answer.
final dynamic userAnswer;
/// Raw correct answer (index, bool or string).
final dynamic targetAnswer;
/// Whether the user answered correctly.
final bool isCorrect;
/// Time spent answering (seconds).
final double timeSpent;
/// Option texts for optiontype questions.
final List<String>? options;
bool get _isOptionType => type == 'option';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.04), color: Colors.black.withOpacity(.04),
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@ -48,41 +69,89 @@ class QuizItemWAComponent extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Question text
Text( Text(
'$index. $question', '$index. $question',
softWrap: true,
style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
), ),
if (_isOptionType && options != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
if (isOptionType && options != null) _buildOptions(), _OptionsList(
options: options!,
userAnswer: userAnswer,
targetAnswer: targetAnswer,
),
],
const SizedBox(height: 12), const SizedBox(height: 12),
_buildAnswerIndicator(context), _AnswerIndicator(
isCorrect: isCorrect,
isAnswered: userAnswer != null && userAnswer != -1,
userAnswerText: _buildUserAnswerText(),
correctAnswerText: _buildCorrectAnswerText(),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 24, color: AppColors.shadowPrimary), const Divider(height: 24, color: AppColors.shadowPrimary),
_buildMetadata(), _MetaBar(type: type, timeSpent: timeSpent),
], ],
), ),
); );
} }
Widget _buildOptions() { // Helpers
String _buildUserAnswerText() {
if (userAnswer == null || userAnswer == -1) {
return tr('not_answered');
}
if (_isOptionType) {
final idx = int.tryParse(userAnswer.toString()) ?? -1;
if (idx >= 0 && idx < (options?.length ?? 0)) return options![idx];
}
return userAnswer.toString();
}
String _buildCorrectAnswerText() {
if (_isOptionType && options != null) {
final idx = int.tryParse(targetAnswer.toString()) ?? -1;
if (idx >= 0 && idx < options!.length) return options![idx];
}
return targetAnswer.toString();
}
}
// Subwidgets
class _OptionsList extends StatelessWidget {
const _OptionsList({
required this.options,
required this.userAnswer,
required this.targetAnswer,
});
final List<String> options;
final dynamic userAnswer;
final dynamic targetAnswer;
@override
Widget build(BuildContext context) {
return Column( return Column(
children: options!.asMap().entries.map((entry) { children: List.generate(options.length, (i) {
final int optIndex = entry.key; final text = options[i];
final String text = entry.value; final bool isCorrectAnswer = i == targetAnswer;
final bool isUserWrongAnswer = i == userAnswer && !isCorrectAnswer;
final bool isCorrectAnswer = optIndex == targetAnswer; Color? bg;
final bool isUserWrongAnswer = optIndex == userAnswer && !isCorrectAnswer;
Color? backgroundColor;
IconData icon = LucideIcons.circle; IconData icon = LucideIcons.circle;
Color iconColor = AppColors.shadowPrimary; Color iconColor = AppColors.shadowPrimary;
if (isCorrectAnswer) { if (isCorrectAnswer) {
backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15); bg = AppColors.primaryBlue.withOpacity(.15);
icon = LucideIcons.checkCircle2; icon = LucideIcons.checkCircle2;
iconColor = AppColors.primaryBlue; iconColor = AppColors.primaryBlue;
} else if (isUserWrongAnswer) { } else if (isUserWrongAnswer) {
backgroundColor = Colors.red.withValues(alpha: 0.15); bg = Colors.red.withOpacity(.15);
icon = LucideIcons.xCircle; icon = LucideIcons.xCircle;
iconColor = Colors.red; iconColor = Colors.red;
} }
@ -92,58 +161,79 @@ class QuizItemWAComponent extends StatelessWidget {
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: bg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.shadowPrimary), border: Border.all(color: AppColors.shadowPrimary),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 16, color: iconColor), Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Expanded(
child: Text(text, style: AppTextStyles.optionText), child: Text(
text,
style: AppTextStyles.optionText,
softWrap: true,
),
), ),
], ],
), ),
); );
}).toList(), }),
); );
} }
}
Widget _buildAnswerIndicator(BuildContext context) { class _AnswerIndicator extends StatelessWidget {
const _AnswerIndicator({
required this.isCorrect,
required this.isAnswered,
required this.userAnswerText,
required this.correctAnswerText,
});
final bool isCorrect;
final bool isAnswered;
final String userAnswerText;
final String correctAnswerText;
@override
Widget build(BuildContext context) {
final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle; final icon = isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle;
final color = isCorrect ? AppColors.primaryBlue : Colors.red; final color = isCorrect ? AppColors.primaryBlue : Colors.red;
final String userAnswerText;
if (userAnswer == null || userAnswer == -1) {
userAnswerText = "Tidak Menjawab";
} else {
userAnswerText = isOptionType ? options![userAnswer] : userAnswer.toString();
}
final String correctAnswerText = targetAnswer.toString();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, color: color, size: 18), Icon(icon, color: color, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
context.tr('your_answer', namedArgs: {'answer': userAnswerText}), child: Text(
// "Jawabanmu: <answer>"
tr('your_answer', namedArgs: {'answer': userAnswerText}),
style: AppTextStyles.statValue, style: AppTextStyles.statValue,
softWrap: true,
),
), ),
], ],
), ),
if (!isCorrect && !isOptionType) ...[ if (!isCorrect && isAnswered) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 26), const SizedBox(width: 26), // align with text above
Text( Expanded(
'Jawaban benar: $correctAnswerText', child: Text(
// "Jawaban benar: <answer>"
tr('correct_answer', namedArgs: {'answer': correctAnswerText}),
style: AppTextStyles.caption, style: AppTextStyles.caption,
softWrap: true,
),
), ),
], ],
), ),
@ -151,18 +241,41 @@ class QuizItemWAComponent extends StatelessWidget {
], ],
); );
} }
}
Widget _buildMetadata() { class _MetaBar extends StatelessWidget {
const _MetaBar({required this.type, required this.timeSpent});
final String type;
final double timeSpent;
@override
Widget build(BuildContext context) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_metaItem(icon: LucideIcons.helpCircle, label: type), _MetaItem(
_metaItem(icon: LucideIcons.clock3, label: '${timeSpent.toStringAsFixed(1)}s'), icon: LucideIcons.helpCircle,
label: tr(
'quiz_type_$type',
),
),
_MetaItem(
icon: LucideIcons.clock3,
label: '${timeSpent.toStringAsFixed(1)}${tr('seconds_suffix')}',
),
], ],
); );
} }
}
Widget _metaItem({required IconData icon, required String label}) { class _MetaItem extends StatelessWidget {
const _MetaItem({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Icon(icon, size: 16, color: AppColors.primaryBlue), Icon(icon, size: 16, color: AppColors.primaryBlue),