develop #1

Merged
akhdanre merged 104 commits from develop into main 2025-07-10 12:38:53 +07:00
4 changed files with 192 additions and 61 deletions
Showing only changes of commit c84133a372 - Show all commits

View File

@ -47,12 +47,10 @@
"avg_score": "Average Score",
"history_detail_title": "Quiz Detail",
"correct_answer": "Correct",
"score": "Score",
"time_taken": "Time",
"duration_seconds": "{second}s",
"your_answer": "Your answer: {answer}",
"question_type_option": "Multiple Choice",
"question_type_fill": "Fill in the Blank",
"question_type_true_false": "True / False",
@ -105,5 +103,13 @@
"contact_us": "Contact Us",
"about_app": "About App",
"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",
"history_detail_title": "Detail Kuis",
"correct_answer": "Benar",
"score": "Skor",
"time_taken": "Waktu",
"duration_seconds": "{second} detik",
"your_answer": "Jawaban kamu: {answer}",
"question_type_option": "Pilihan Ganda",
"question_type_fill": "Isian Kosong",
"question_type_true_false": "Benar / Salah",
@ -105,5 +103,13 @@
"contact_us": "Hubungi Kami",
"about_app": "Tentang Aplikasi",
"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",
"history_detail_title": "Butiran Kuiz",
"correct_answer": "Betul",
"score": "Skor",
"time_taken": "Masa",
"duration_seconds": "{second} saat",
"your_answer": "Jawapan anda: {answer}",
"question_type_option": "Pilihan Berganda",
"question_type_fill": "Isi Tempat Kosong",
"question_type_true_false": "Betul / Salah",
@ -102,5 +100,13 @@
"contact_us": "Hubungi Kami",
"about_app": "Mengenai Aplikasi",
"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/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 {
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({
super.key,
required this.index,
@ -26,20 +23,44 @@ class QuizItemWAComponent extends StatelessWidget {
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
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
color: Colors.black.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
@ -48,41 +69,89 @@ class QuizItemWAComponent extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Question text
Text(
'$index. $question',
softWrap: true,
style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
if (isOptionType && options != null) _buildOptions(),
if (_isOptionType && options != null) ...[
const SizedBox(height: 16),
_OptionsList(
options: options!,
userAnswer: userAnswer,
targetAnswer: targetAnswer,
),
],
const SizedBox(height: 12),
_buildAnswerIndicator(context),
_AnswerIndicator(
isCorrect: isCorrect,
isAnswered: userAnswer != null && userAnswer != -1,
userAnswerText: _buildUserAnswerText(),
correctAnswerText: _buildCorrectAnswerText(),
),
const SizedBox(height: 16),
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(
children: options!.asMap().entries.map((entry) {
final int optIndex = entry.key;
final String text = entry.value;
children: List.generate(options.length, (i) {
final text = options[i];
final bool isCorrectAnswer = i == targetAnswer;
final bool isUserWrongAnswer = i == userAnswer && !isCorrectAnswer;
final bool isCorrectAnswer = optIndex == targetAnswer;
final bool isUserWrongAnswer = optIndex == userAnswer && !isCorrectAnswer;
Color? backgroundColor;
Color? bg;
IconData icon = LucideIcons.circle;
Color iconColor = AppColors.shadowPrimary;
if (isCorrectAnswer) {
backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15);
bg = AppColors.primaryBlue.withOpacity(.15);
icon = LucideIcons.checkCircle2;
iconColor = AppColors.primaryBlue;
} else if (isUserWrongAnswer) {
backgroundColor = Colors.red.withValues(alpha: 0.15);
bg = Colors.red.withOpacity(.15);
icon = LucideIcons.xCircle;
iconColor = Colors.red;
}
@ -92,58 +161,79 @@ class QuizItemWAComponent extends StatelessWidget {
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
color: backgroundColor,
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.shadowPrimary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 8),
Flexible(
child: Text(text, style: AppTextStyles.optionText),
Expanded(
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 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
Text(
context.tr('your_answer', namedArgs: {'answer': userAnswerText}),
style: AppTextStyles.statValue,
Expanded(
child: Text(
// "Jawabanmu: <answer>"
tr('your_answer', namedArgs: {'answer': userAnswerText}),
style: AppTextStyles.statValue,
softWrap: true,
),
),
],
),
if (!isCorrect && !isOptionType) ...[
if (!isCorrect && isAnswered) ...[
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 26),
Text(
'Jawaban benar: $correctAnswerText',
style: AppTextStyles.caption,
const SizedBox(width: 26), // align with text above
Expanded(
child: Text(
// "Jawaban benar: <answer>"
tr('correct_answer', namedArgs: {'answer': correctAnswerText}),
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_metaItem(icon: LucideIcons.helpCircle, label: type),
_metaItem(icon: LucideIcons.clock3, label: '${timeSpent.toStringAsFixed(1)}s'),
_MetaItem(
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(
children: [
Icon(icon, size: 16, color: AppColors.primaryBlue),